Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/akitaonrails_ai-usagebar.json
T

1 line
2.8 MiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "akitaonrails/ai-usagebar", "files": {"config.example.toml": {"997730bf6f794e7d3abac3fee5927796e434f67c": {"t": 1785745318.7769537, "n": 12054, "h": "4d6991f4d8b727a7", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek | kimi\n# When unset, the TUI opens on the Overview (all vendors at once); set this to\n# open directly on a vendor's tab instead.\n# primary = \"anthropic\"\n# Which vendors the Overview lists (the TUI's first tab / the macOS menu-bar's\n# top section), in this order. Omit for every enabled vendor.\n# overview_vendors = [\"anthropic\", \"cursor\", \"openai\"]\n# Presentation of vendor tab navigation: sidebar (vertical box) | navbar (top strip) | none (hidden).\n# Default sidebar (falls back to navbar on narrow terminals).\n# vendor_box = \"sidebar\"\n\n# Optional, TUI-only monitor for local Claude Code session context. When\n# enabled, press `c` in ai-usagebar-tui. The scanner reads only bounded tails\n# below ~/.claude/projects, skips subagent transcripts and never follows\n# discovered symlinks. Nothing is read while this remains disabled.\n[context]\nenabled = false\n# Where the view docks when opened: full (own screen) | split (beside the\n# dashboard) | bottom. `v` cycles it while open; this is the starting value.\nlayout = \"full\"\n# projects_path = \"~/.claude/projects\" # default\n# Set a fallback only when it matches the sessions you use. Without a known\n# denominator the overlay shows raw input tokens rather than inventing a %.\n# context_window_tokens = 200000\n# Exact per-model values take precedence, which is useful when 200K and 1M\n# sessions coexist. Keys must match the model id stored in the transcript.\n# [context.model_context_window_tokens]\n# \"claude-opus-4-6\" = 1000000\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n# Auto-discover extra accounts from a directory, in Claude Code's own\n# CLAUDE_CONFIG_DIR layout: each immediate subdir becomes an account labeled by\n# the subdir name (shown as a TUI tab and selectable with --account <label>),\n# refreshed independently. Credentials live in .credentials.json on Linux and\n# may live only in a config-dir-scoped Keychain item on macOS. Populate it with:\n# CLAUDE_CONFIG_DIR=~/.config/ai-usagebar/accounts/work claude # sign in\n# Merged with the explicit [[anthropic.accounts]] entries below (explicit wins).\n# accounts_dir = \"~/.config/ai-usagebar/accounts\"\n# Hide the default (unnamed) Claude tab when you manage every account explicitly\n# above — otherwise you get an extra tab for the ambient ~/.claude / Keychain\n# login. Default true; ignored when there are no named accounts.\n# show_default_account = false\n# Where the Claude *Desktop app*'s saved accounts live, for `account switch`.\n# Defaults to the store claude-acc creates (https://github.com/ohmaseclaro/claude-acc);\n# unrelated to accounts_dir above, which is the `claude` CLI's own accounts.\n# desktop_profiles_dir = \"~/.claude-acc/profiles\"\n# Or list accounts explicitly:\n# [[anthropic.accounts]]\n# label = \"work\"\n# credentials_path = \"~/.config/ai-usagebar/accounts/work/.credentials.json\"\n\n# Anthropic (API) — unlike the balance vendors above (which show your REMAINING\n# credit), Anthropic does not expose the prepaid credit balance over any API\n# (Console dashboard only), so this reports your month-to-date SPEND (consumed)\n# instead — for your API/Console account, which is separate from the Claude Code\n# OAuth login above.\n#\n# NOTE: the cost API does NOT report Priority Tier usage, so if your\n# organization is on Priority Tier the figure shown here is LOWER than your\n# real total spend. See\n# https://platform.claude.com/docs/en/manage-claude/usage-cost-api\n#\n# Opt-in (disabled until you set a key). Requires a Console *Admin key*\n# (`sk-ant-admin01-...`, sent as the `x-api-key` header), distinct from an\n# inference key. Admin keys exist only for ORGANIZATION accounts: set one up\n# first (Console > Settings > Organization) — the .../settings/admin-keys page\n# 404s for individual accounts.\n[anthropic_api]\nenabled = false\napi_key_env = \"ANTHROPIC_ADMIN_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-ant-admin01-...\" # fallback; chmod 600 the file if you use it\n# Positive, finite monthly USD limit for the spend-vs-limit % display (the API\n# doesn't expose a limit). With it: \"$1.34 / $1000 · 0%\". Without it:\n# \"$1.34/mo\".\n# monthly_limit = 1000\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# RESERVED — setting this does nothing today. It names the env var a future\n# API-key-only path would read (admin key → `/v1/organization/costs`). No code\n# consumes it: OpenAI usage comes only from Codex OAuth. Left commented out so\n# it isn't mistaken for a working alternative to `codex login`.\n# admin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n[kimi]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Shows Kimi Code subscription quota (weekly + 5h rolling window) from the\n# undocumented, community-confirmed api.kimi.com/coding/v1/usages endpoint.\nenabled = false\napi_key_env = \"KIMI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n# --- Account-balance vendors -------------------------------------------------\n# These report your REMAINING credit as money, rather than plan usage as a %.\n# All are opt-in and never fetch until enabled with a key present.\n\n[kilo]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the undocumented api.kilo.ai/api/profile/balance\n# endpoint (used internally by the Kilo Code extension).\nenabled = false\napi_key_env = \"KILO_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Scopes the balance to a team via the x-kilocode-organizationid header.\n# Omit for the personal balance. Switching this refetches rather than reusing\n# the previous account's cached figure.\n# organization_id = \"org_...\"\n\n[novita]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the documented\n# api.novita.ai/openapi/v1/billing/balance/detail endpoint.\nenabled = false\napi_key_env = \"NOVITA_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n\n[moonshot]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Account balance from the documented /v1/users/me/balance endpoint.\nenabled = false\napi_key_env = \"MOONSHOT_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n# Region picks BOTH the host and the currency, so switching it refetches\n# rather than showing the other region's figure:\n# global -> api.moonshot.ai (USD) | cn -> api.moonshot.cn (CNY)\n# region = \"global\"\n\n[grok]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Prepaid credit from the documented xAI Management API\n# management-api.x.ai/v1/billing/teams/{team}/prepaid/balance.\n#\n# This is the *Management* key (xAI Console > Management keys), NOT the\n# inference key you use for chat completions.\nenabled = false\napi_key_env = \"XAI_MANAGEMENT_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# The balance is per-team. A TEAM-scoped management key identifies its team on\n# its own; an ORGANIZATION-scoped key cannot (its scopeId is an organization,\n# not a team), so set the team explicitly here. Without it, an org-scoped key\n# reports an error saying so instead of querying the wrong team.\n# team_id = \"...\"\n\n# Google Antigravity. No credentials: usage is read from whichever local\n# Antigravity product is running (Antigravity 2.0, the `agy` CLI, or the IDE) —\n# all three share one account-wide quota. Set ANTIGRAVITY_LS_ADDRESS to\n# \"host:port\" only if auto-discovery of that local server ever fails.\n[antigravity]\nenabled = false\n\n# Cursor. No API key: usage is read from the session token the Cursor IDE\n# already wrote to its own local state database after you signed in there —\n# no separate login step for ai-usagebar. Shows this billing cycle's two\n# included-usage pools — \"Cursor Models\" (Auto + Composer) and \"Other Models\"\n# (named / API) — from the undocumented cursor.com/api/usage-summary endpoint\n# (the same one the Cursor dashboard itself calls).\n[cursor]\n# Disabled by default — enable once you've signed in to the Cursor IDE at\n# least once (it needs no key from you, but it does need that one-time login\n# to exist).\nenabled = false\n# Override Cursor's local state database path. Leave commented out to use the\n# platform default:\n# Linux: ~/.config/Cursor/User/globalStorage/state.vscdb\n# macOS: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb\n# Windows: %APPDATA%\\Cursor\\User\\globalStorage\\state.vscdb\n# Set this if you use a portable Cursor install or a renamed profile dir.\n# db_path = \"/home/you/.config/Cursor/User/globalStorage/state.vscdb\"\n\n[minimax]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Token Plan subscription quota (rolling interval window + weekly) from the\n# official /v1/token_plan/remains route. Needs the Token Plan SUBSCRIPTION key\n# — a pay-as-you-go key has no plan quota to report.\nenabled = false\napi_key_env = \"MINIMAX_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Region picks the INSTANCE, and the two issue separate keys — pointing this\n# at the wrong one reads as an invalid key, not an empty plan:\n# global -> api.minimax.io | cn -> api.minimaxi.com\n# region = \"global\"\n"}, "9a14533a077db799f3e71a0d1d9d8309217e7642": {"t": 1785750116.1980233, "n": 12054, "h": "4d6991f4d8b727a7", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek | kimi\n# When unset, the TUI opens on the Overview (all vendors at once); set this to\n# open directly on a vendor's tab instead.\n# primary = \"anthropic\"\n# Which vendors the Overview lists (the TUI's first tab / the macOS menu-bar's\n# top section), in this order. Omit for every enabled vendor.\n# overview_vendors = [\"anthropic\", \"cursor\", \"openai\"]\n# Presentation of vendor tab navigation: sidebar (vertical box) | navbar (top strip) | none (hidden).\n# Default sidebar (falls back to navbar on narrow terminals).\n# vendor_box = \"sidebar\"\n\n# Optional, TUI-only monitor for local Claude Code session context. When\n# enabled, press `c` in ai-usagebar-tui. The scanner reads only bounded tails\n# below ~/.claude/projects, skips subagent transcripts and never follows\n# discovered symlinks. Nothing is read while this remains disabled.\n[context]\nenabled = false\n# Where the view docks when opened: full (own screen) | split (beside the\n# dashboard) | bottom. `v` cycles it while open; this is the starting value.\nlayout = \"full\"\n# projects_path = \"~/.claude/projects\" # default\n# Set a fallback only when it matches the sessions you use. Without a known\n# denominator the overlay shows raw input tokens rather than inventing a %.\n# context_window_tokens = 200000\n# Exact per-model values take precedence, which is useful when 200K and 1M\n# sessions coexist. Keys must match the model id stored in the transcript.\n# [context.model_context_window_tokens]\n# \"claude-opus-4-6\" = 1000000\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n# Auto-discover extra accounts from a directory, in Claude Code's own\n# CLAUDE_CONFIG_DIR layout: each immediate subdir becomes an account labeled by\n# the subdir name (shown as a TUI tab and selectable with --account <label>),\n# refreshed independently. Credentials live in .credentials.json on Linux and\n# may live only in a config-dir-scoped Keychain item on macOS. Populate it with:\n# CLAUDE_CONFIG_DIR=~/.config/ai-usagebar/accounts/work claude # sign in\n# Merged with the explicit [[anthropic.accounts]] entries below (explicit wins).\n# accounts_dir = \"~/.config/ai-usagebar/accounts\"\n# Hide the default (unnamed) Claude tab when you manage every account explicitly\n# above — otherwise you get an extra tab for the ambient ~/.claude / Keychain\n# login. Default true; ignored when there are no named accounts.\n# show_default_account = false\n# Where the Claude *Desktop app*'s saved accounts live, for `account switch`.\n# Defaults to the store claude-acc creates (https://github.com/ohmaseclaro/claude-acc);\n# unrelated to accounts_dir above, which is the `claude` CLI's own accounts.\n# desktop_profiles_dir = \"~/.claude-acc/profiles\"\n# Or list accounts explicitly:\n# [[anthropic.accounts]]\n# label = \"work\"\n# credentials_path = \"~/.config/ai-usagebar/accounts/work/.credentials.json\"\n\n# Anthropic (API) — unlike the balance vendors above (which show your REMAINING\n# credit), Anthropic does not expose the prepaid credit balance over any API\n# (Console dashboard only), so this reports your month-to-date SPEND (consumed)\n# instead — for your API/Console account, which is separate from the Claude Code\n# OAuth login above.\n#\n# NOTE: the cost API does NOT report Priority Tier usage, so if your\n# organization is on Priority Tier the figure shown here is LOWER than your\n# real total spend. See\n# https://platform.claude.com/docs/en/manage-claude/usage-cost-api\n#\n# Opt-in (disabled until you set a key). Requires a Console *Admin key*\n# (`sk-ant-admin01-...`, sent as the `x-api-key` header), distinct from an\n# inference key. Admin keys exist only for ORGANIZATION accounts: set one up\n# first (Console > Settings > Organization) — the .../settings/admin-keys page\n# 404s for individual accounts.\n[anthropic_api]\nenabled = false\napi_key_env = \"ANTHROPIC_ADMIN_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-ant-admin01-...\" # fallback; chmod 600 the file if you use it\n# Positive, finite monthly USD limit for the spend-vs-limit % display (the API\n# doesn't expose a limit). With it: \"$1.34 / $1000 · 0%\". Without it:\n# \"$1.34/mo\".\n# monthly_limit = 1000\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# RESERVED — setting this does nothing today. It names the env var a future\n# API-key-only path would read (admin key → `/v1/organization/costs`). No code\n# consumes it: OpenAI usage comes only from Codex OAuth. Left commented out so\n# it isn't mistaken for a working alternative to `codex login`.\n# admin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n[kimi]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Shows Kimi Code subscription quota (weekly + 5h rolling window) from the\n# undocumented, community-confirmed api.kimi.com/coding/v1/usages endpoint.\nenabled = false\napi_key_env = \"KIMI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n# --- Account-balance vendors -------------------------------------------------\n# These report your REMAINING credit as money, rather than plan usage as a %.\n# All are opt-in and never fetch until enabled with a key present.\n\n[kilo]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the undocumented api.kilo.ai/api/profile/balance\n# endpoint (used internally by the Kilo Code extension).\nenabled = false\napi_key_env = \"KILO_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Scopes the balance to a team via the x-kilocode-organizationid header.\n# Omit for the personal balance. Switching this refetches rather than reusing\n# the previous account's cached figure.\n# organization_id = \"org_...\"\n\n[novita]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the documented\n# api.novita.ai/openapi/v1/billing/balance/detail endpoint.\nenabled = false\napi_key_env = \"NOVITA_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n\n[moonshot]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Account balance from the documented /v1/users/me/balance endpoint.\nenabled = false\napi_key_env = \"MOONSHOT_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n# Region picks BOTH the host and the currency, so switching it refetches\n# rather than showing the other region's figure:\n# global -> api.moonshot.ai (USD) | cn -> api.moonshot.cn (CNY)\n# region = \"global\"\n\n[grok]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Prepaid credit from the documented xAI Management API\n# management-api.x.ai/v1/billing/teams/{team}/prepaid/balance.\n#\n# This is the *Management* key (xAI Console > Management keys), NOT the\n# inference key you use for chat completions.\nenabled = false\napi_key_env = \"XAI_MANAGEMENT_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# The balance is per-team. A TEAM-scoped management key identifies its team on\n# its own; an ORGANIZATION-scoped key cannot (its scopeId is an organization,\n# not a team), so set the team explicitly here. Without it, an org-scoped key\n# reports an error saying so instead of querying the wrong team.\n# team_id = \"...\"\n\n# Google Antigravity. No credentials: usage is read from whichever local\n# Antigravity product is running (Antigravity 2.0, the `agy` CLI, or the IDE) —\n# all three share one account-wide quota. Set ANTIGRAVITY_LS_ADDRESS to\n# \"host:port\" only if auto-discovery of that local server ever fails.\n[antigravity]\nenabled = false\n\n# Cursor. No API key: usage is read from the session token the Cursor IDE\n# already wrote to its own local state database after you signed in there —\n# no separate login step for ai-usagebar. Shows this billing cycle's two\n# included-usage pools — \"Cursor Models\" (Auto + Composer) and \"Other Models\"\n# (named / API) — from the undocumented cursor.com/api/usage-summary endpoint\n# (the same one the Cursor dashboard itself calls).\n[cursor]\n# Disabled by default — enable once you've signed in to the Cursor IDE at\n# least once (it needs no key from you, but it does need that one-time login\n# to exist).\nenabled = false\n# Override Cursor's local state database path. Leave commented out to use the\n# platform default:\n# Linux: ~/.config/Cursor/User/globalStorage/state.vscdb\n# macOS: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb\n# Windows: %APPDATA%\\Cursor\\User\\globalStorage\\state.vscdb\n# Set this if you use a portable Cursor install or a renamed profile dir.\n# db_path = \"/home/you/.config/Cursor/User/globalStorage/state.vscdb\"\n\n[minimax]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Token Plan subscription quota (rolling interval window + weekly) from the\n# official /v1/token_plan/remains route. Needs the Token Plan SUBSCRIPTION key\n# — a pay-as-you-go key has no plan quota to report.\nenabled = false\napi_key_env = \"MINIMAX_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Region picks the INSTANCE, and the two issue separate keys — pointing this\n# at the wrong one reads as an invalid key, not an empty plan:\n# global -> api.minimax.io | cn -> api.minimaxi.com\n# region = \"global\"\n"}, "b2facd624a4352d2c3de8ae61fd537aa482a5cdf": {"t": 1785750117.313587, "n": 12061, "h": "75962773bd4bd771", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek | kimi\n# When unset, the TUI opens on the Overview (all vendors at once); set this to\n# open directly on a vendor's tab instead.\n# primary = \"anthropic\"\n# Which vendors the Overview lists (the TUI's first tab / the macOS menu-bar's\n# top section), in this order. Omit for every enabled vendor.\n# overview_vendors = [\"anthropic\", \"cursor\", \"openai\"]\n# Presentation of vendor tab navigation: sidebar (vertical box) | navbar (top strip) | none (hidden).\n# Default sidebar (falls back to navbar on narrow terminals).\n# vendor_box = \"sidebar\"\n\n# Optional, TUI-only monitor for local Claude Code session context. When\n# enabled, press `c` in ai-usagebar-tui. The scanner reads only bounded tails\n# below ~/.claude/projects, skips subagent transcripts and never follows\n# discovered symlinks. Nothing is read while this remains disabled.\n[context]\nenabled = false\n# Where the view docks when opened: full (own screen) | split (beside the\n# dashboard) | bottom. `v` cycles it while open; this is the starting value.\nlayout = \"full\"\n# projects_path = \"~/.claude/projects\" # default\n# Set a fallback only when it matches the sessions you use. Without a known\n# denominator the overlay shows raw input tokens rather than inventing a %.\n# context_window_tokens = 200000\n# Exact per-model values take precedence, which is useful when 200K and 1M\n# sessions coexist. Keys must match the model id stored in the transcript.\n# [context.model_context_window_tokens]\n# \"claude-opus-4-6\" = 1000000\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n# Auto-discover extra accounts from a directory, in Claude Code's own\n# CLAUDE_CONFIG_DIR layout: each immediate subdir becomes an account labeled by\n# the subdir name (shown as a TUI tab and selectable with --account <label>),\n# refreshed independently. Credentials live in .credentials.json on Linux and\n# may live only in a config-dir-scoped Keychain item on macOS. Populate it with:\n# CLAUDE_CONFIG_DIR=~/.config/ai-usagebar/accounts/work claude # sign in\n# Merged with the explicit [[anthropic.accounts]] entries below (explicit wins).\n# accounts_dir = \"~/.config/ai-usagebar/accounts\"\n# Hide the default (unnamed) Claude tab when you manage every account explicitly\n# above — otherwise you get an extra tab for the ambient ~/.claude / Keychain\n# login. Default true; ignored when there are no named accounts.\n# show_default_account = false\n# Where the Claude *Desktop app*'s saved accounts live, for `account switch`.\n# Defaults to the store claude-acc creates (https://github.com/ohmaseclaro/claude-acc);\n# unrelated to accounts_dir above, which is the `claude` CLI's own accounts.\n# desktop_profiles_dir = \"~/.claude-acc/profiles\"\n# Or list accounts explicitly:\n# [[anthropic.accounts]]\n# label = \"work\"\n# credentials_path = \"~/.config/ai-usagebar/accounts/work/.credentials.json\"\n\n# Anthropic (API) — unlike the balance vendors above (which show your REMAINING\n# credit), Anthropic does not expose the prepaid credit balance over any API\n# (Console dashboard only), so this reports your month-to-date SPEND (consumed)\n# instead — for your API/Console account, which is separate from the Claude Code\n# OAuth login above.\n#\n# NOTE: the cost API does NOT report Priority Tier usage, so if your\n# organization is on Priority Tier the figure shown here is LOWER than your\n# real total spend. See\n# https://platform.claude.com/docs/en/manage-claude/usage-cost-api\n#\n# Opt-in (disabled until you set a key). Requires a Console *Admin key*\n# (`sk-ant-admin01-...`, sent as the `x-api-key` header), distinct from an\n# inference key. Admin keys exist only for ORGANIZATION accounts: set one up\n# first (Console > Settings > Organization) — the .../settings/admin-keys page\n# 404s for individual accounts.\n[anthropic_api]\nenabled = false\napi_key_env = \"ANTHROPIC_ADMIN_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-ant-admin01-...\" # fallback; chmod 600 the file if you use it\n# Positive, finite monthly USD limit for the spend-vs-limit % display (the API\n# doesn't expose a limit). With it: \"$1.34 / $1000 · 0%\". Without it:\n# \"$1.34/mo\".\n# monthly_limit = 1000\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# RESERVED — setting this does nothing today. It names the env var a future\n# API-key-only path would read (admin key → `/v1/organization/costs`). No code\n# consumes it: OpenAI usage comes only from Codex OAuth. Left commented out so\n# it isn't mistaken for a working alternative to `codex login`.\n# admin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n[kimi]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Shows Kimi Code subscription quota (weekly + 5h rolling window) from the\n# undocumented, community-confirmed api.kimi.com/coding/v1/usages endpoint.\nenabled = false\napi_key_env = \"KIMI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n# --- Account-balance vendors -------------------------------------------------\n# These report your REMAINING credit as money, rather than plan usage as a %.\n# All are opt-in and never fetch until enabled with a key present.\n\n[kilo]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the undocumented api.kilo.ai/api/profile/balance\n# endpoint (used internally by the Kilo Code extension).\nenabled = false\napi_key_env = \"KILO_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Scopes the balance to a team via the x-kilocode-organizationid header.\n# Omit for the personal balance. Switching this refetches rather than reusing\n# the previous account's cached figure.\n# organization_id = \"org_...\"\n\n[novita]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the documented\n# api.novita.ai/openapi/v1/billing/balance/detail endpoint.\nenabled = false\napi_key_env = \"NOVITA_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n\n[moonshot]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Account balance from the documented /v1/users/me/balance endpoint.\nenabled = false\napi_key_env = \"MOONSHOT_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n# Region picks BOTH the host and the currency, so switching it refetches\n# rather than showing the other region's figure:\n# global -> api.moonshot.ai (USD) | cn -> api.moonshot.cn (CNY)\n# region = \"global\"\n\n[grok]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Prepaid credit from the documented xAI Management API\n# management-api.x.ai/v1/billing/teams/{team}/prepaid/balance.\n#\n# This is the *Management* key (xAI Console > Management keys), NOT the\n# inference key you use for chat completions.\nenabled = false\napi_key_env = \"XAI_MANAGEMENT_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# The balance is per-team. A TEAM-scoped management key identifies its team on\n# its own; an ORGANIZATION-scoped key cannot (its scopeId is an organization,\n# not a team), so set the team explicitly here. Without it, an org-scoped key\n# reports an error saying so instead of querying the wrong team.\n# team_id = \"...\"\n\n# Google Antigravity. No credentials: usage is read from whichever local\n# Antigravity product is running (Antigravity 2.0, the `agy` CLI, or the IDE) —\n# all three share one account-wide quota. Set ANTIGRAVITY_LS_ADDRESS to\n# \"host:port\" only if auto-discovery of that local server ever fails.\n[antigravity]\nenabled = false\n\n# Cursor. No API key: usage is read from the session token the Cursor IDE\n# already wrote to its own local state database after you signed in there —\n# no separate login step for ai-usagebar. Shows this billing cycle's two\n# included-usage pools — \"Cursor Models\" (Auto + Composer) and \"Other Models\"\n# (named / API) — from the undocumented cursor.com/api/usage-summary endpoint\n# (the same one the Cursor dashboard itself calls).\n[cursor]\n# Disabled by default — enable once you've signed in to the Cursor IDE at\n# least once (it needs no key from you, but it does need that one-time login\n# to exist).\nenabled = false\n# Override Cursor's local state database path. Leave commented out to use the\n# platform default:\n# Linux: ~/.config/Cursor/User/globalStorage/state.vscdb\n# macOS: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb\n# Windows: %APPDATA%\\Cursor\\User\\globalStorage\\state.vscdb\n# Set this if you use a portable Cursor install or a renamed profile dir.\n# db_path = \"/home/you/.config/Cursor/User/globalStorage/state.vscdb\"\n\n[minimax]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Token Plan subscription quota (rolling interval window + weekly) from the\n# undocumented /v1/token_plan/remains endpoint. Needs the Token Plan\n# SUBSCRIPTION key — a pay-as-you-go key has no plan quota to report.\nenabled = false\napi_key_env = \"MINIMAX_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Region picks the INSTANCE, and the two issue separate keys — pointing this\n# at the wrong one reads as an invalid key, not an empty plan:\n# global -> api.minimax.io | cn -> api.minimaxi.com\n# region = \"global\"\n"}, "951192a37a6eb41892f29fab13d6b71c85dc8222": {"t": 1785750118.5097392, "n": 11224, "h": "0a73475306fb7ef0", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek | kimi\n# When unset, the TUI opens on the Overview (all vendors at once); set this to\n# open directly on a vendor's tab instead.\n# primary = \"anthropic\"\n# Which vendors the Overview lists (the TUI's first tab / the macOS menu-bar's\n# top section), in this order. Omit for every enabled vendor.\n# overview_vendors = [\"anthropic\", \"cursor\", \"openai\"]\n# Presentation of vendor tab navigation: sidebar (vertical box) | navbar (top strip) | none (hidden).\n# Default sidebar (falls back to navbar on narrow terminals).\n# vendor_box = \"sidebar\"\n\n# Optional, TUI-only monitor for local Claude Code session context. When\n# enabled, press `c` in ai-usagebar-tui. The scanner reads only bounded tails\n# below ~/.claude/projects, skips subagent transcripts and never follows\n# discovered symlinks. Nothing is read while this remains disabled.\n[context]\nenabled = false\n# Where the view docks when opened: full (own screen) | split (beside the\n# dashboard) | bottom. `v` cycles it while open; this is the starting value.\nlayout = \"full\"\n# projects_path = \"~/.claude/projects\" # default\n# Set a fallback only when it matches the sessions you use. Without a known\n# denominator the overlay shows raw input tokens rather than inventing a %.\n# context_window_tokens = 200000\n# Exact per-model values take precedence, which is useful when 200K and 1M\n# sessions coexist. Keys must match the model id stored in the transcript.\n# [context.model_context_window_tokens]\n# \"claude-opus-4-6\" = 1000000\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n# Auto-discover extra accounts from a directory, in Claude Code's own\n# CLAUDE_CONFIG_DIR layout: each immediate subdir becomes an account labeled by\n# the subdir name (shown as a TUI tab and selectable with --account <label>),\n# refreshed independently. Credentials live in .credentials.json on Linux and\n# may live only in a config-dir-scoped Keychain item on macOS. Populate it with:\n# CLAUDE_CONFIG_DIR=~/.config/ai-usagebar/accounts/work claude # sign in\n# Merged with the explicit [[anthropic.accounts]] entries below (explicit wins).\n# accounts_dir = \"~/.config/ai-usagebar/accounts\"\n# Hide the default (unnamed) Claude tab when you manage every account explicitly\n# above — otherwise you get an extra tab for the ambient ~/.claude / Keychain\n# login. Default true; ignored when there are no named accounts.\n# show_default_account = false\n# Where the Claude *Desktop app*'s saved accounts live, for `account switch`.\n# Defaults to the store claude-acc creates (https://github.com/ohmaseclaro/claude-acc);\n# unrelated to accounts_dir above, which is the `claude` CLI's own accounts.\n# desktop_profiles_dir = \"~/.claude-acc/profiles\"\n# Or list accounts explicitly:\n# [[anthropic.accounts]]\n# label = \"work\"\n# credentials_path = \"~/.config/ai-usagebar/accounts/work/.credentials.json\"\n\n# Anthropic (API) — unlike the balance vendors above (which show your REMAINING\n# credit), Anthropic does not expose the prepaid credit balance over any API\n# (Console dashboard only), so this reports your month-to-date SPEND (consumed)\n# instead — for your API/Console account, which is separate from the Claude Code\n# OAuth login above.\n#\n# NOTE: the cost API does NOT report Priority Tier usage, so if your\n# organization is on Priority Tier the figure shown here is LOWER than your\n# real total spend. See\n# https://platform.claude.com/docs/en/manage-claude/usage-cost-api\n#\n# Opt-in (disabled until you set a key). Requires a Console *Admin key*\n# (`sk-ant-admin01-...`, sent as the `x-api-key` header), distinct from an\n# inference key. Admin keys exist only for ORGANIZATION accounts: set one up\n# first (Console > Settings > Organization) — the .../settings/admin-keys page\n# 404s for individual accounts.\n[anthropic_api]\nenabled = false\napi_key_env = \"ANTHROPIC_ADMIN_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-ant-admin01-...\" # fallback; chmod 600 the file if you use it\n# Positive, finite monthly USD limit for the spend-vs-limit % display (the API\n# doesn't expose a limit). With it: \"$1.34 / $1000 · 0%\". Without it:\n# \"$1.34/mo\".\n# monthly_limit = 1000\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# RESERVED — setting this does nothing today. It names the env var a future\n# API-key-only path would read (admin key → `/v1/organization/costs`). No code\n# consumes it: OpenAI usage comes only from Codex OAuth. Left commented out so\n# it isn't mistaken for a working alternative to `codex login`.\n# admin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n[kimi]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Shows Kimi Code subscription quota (weekly + 5h rolling window) from the\n# undocumented, community-confirmed api.kimi.com/coding/v1/usages endpoint.\nenabled = false\napi_key_env = \"KIMI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n# --- Account-balance vendors -------------------------------------------------\n# These report your REMAINING credit as money, rather than plan usage as a %.\n# All are opt-in and never fetch until enabled with a key present.\n\n[kilo]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the undocumented api.kilo.ai/api/profile/balance\n# endpoint (used internally by the Kilo Code extension).\nenabled = false\napi_key_env = \"KILO_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Scopes the balance to a team via the x-kilocode-organizationid header.\n# Omit for the personal balance. Switching this refetches rather than reusing\n# the previous account's cached figure.\n# organization_id = \"org_...\"\n\n[novita]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the documented\n# api.novita.ai/openapi/v1/billing/balance/detail endpoint.\nenabled = false\napi_key_env = \"NOVITA_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n\n[moonshot]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Account balance from the documented /v1/users/me/balance endpoint.\nenabled = false\napi_key_env = \"MOONSHOT_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n# Region picks BOTH the host and the currency, so switching it refetches\n# rather than showing the other region's figure:\n# global -> api.moonshot.ai (USD) | cn -> api.moonshot.cn (CNY)\n# region = \"global\"\n\n[grok]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Prepaid credit from the documented xAI Management API\n# management-api.x.ai/v1/billing/teams/{team}/prepaid/balance.\n#\n# This is the *Management* key (xAI Console > Management keys), NOT the\n# inference key you use for chat completions.\nenabled = false\napi_key_env = \"XAI_MANAGEMENT_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# The balance is per-team. A TEAM-scoped management key identifies its team on\n# its own; an ORGANIZATION-scoped key cannot (its scopeId is an organization,\n# not a team), so set the team explicitly here. Without it, an org-scoped key\n# reports an error saying so instead of querying the wrong team.\n# team_id = \"...\"\n\n# Google Antigravity. No credentials: usage is read from whichever local\n# Antigravity product is running (Antigravity 2.0, the `agy` CLI, or the IDE) —\n# all three share one account-wide quota. Set ANTIGRAVITY_LS_ADDRESS to\n# \"host:port\" only if auto-discovery of that local server ever fails.\n[antigravity]\nenabled = false\n\n# Cursor. No API key: usage is read from the session token the Cursor IDE\n# already wrote to its own local state database after you signed in there —\n# no separate login step for ai-usagebar. Shows this billing cycle's two\n# included-usage pools — \"Cursor Models\" (Auto + Composer) and \"Other Models\"\n# (named / API) — from the undocumented cursor.com/api/usage-summary endpoint\n# (the same one the Cursor dashboard itself calls).\n[cursor]\n# Disabled by default — enable once you've signed in to the Cursor IDE at\n# least once (it needs no key from you, but it does need that one-time login\n# to exist).\nenabled = false\n# Override Cursor's local state database path. Leave commented out to use the\n# platform default:\n# Linux: ~/.config/Cursor/User/globalStorage/state.vscdb\n# macOS: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb\n# Windows: %APPDATA%\\Cursor\\User\\globalStorage\\state.vscdb\n# Set this if you use a portable Cursor install or a renamed profile dir.\n# db_path = \"/home/you/.config/Cursor/User/globalStorage/state.vscdb\"\n"}, "6ae76143111d106e5770d6d52522dfafdba9947e": {"t": 1785750119.6258154, "n": 11035, "h": "e2d08d3af5e61559", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek | kimi\n# When unset, the TUI opens on the Overview (all vendors at once); set this to\n# open directly on a vendor's tab instead.\n# primary = \"anthropic\"\n# Which vendors the Overview lists (the TUI's first tab / the macOS menu-bar's\n# top section), in this order. Omit for every enabled vendor.\n# overview_vendors = [\"anthropic\", \"cursor\", \"openai\"]\n\n# Optional, TUI-only monitor for local Claude Code session context. When\n# enabled, press `c` in ai-usagebar-tui. The scanner reads only bounded tails\n# below ~/.claude/projects, skips subagent transcripts and never follows\n# discovered symlinks. Nothing is read while this remains disabled.\n[context]\nenabled = false\n# Where the view docks when opened: full (own screen) | split (beside the\n# dashboard) | bottom. `v` cycles it while open; this is the starting value.\nlayout = \"full\"\n# projects_path = \"~/.claude/projects\" # default\n# Set a fallback only when it matches the sessions you use. Without a known\n# denominator the overlay shows raw input tokens rather than inventing a %.\n# context_window_tokens = 200000\n# Exact per-model values take precedence, which is useful when 200K and 1M\n# sessions coexist. Keys must match the model id stored in the transcript.\n# [context.model_context_window_tokens]\n# \"claude-opus-4-6\" = 1000000\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n# Auto-discover extra accounts from a directory, in Claude Code's own\n# CLAUDE_CONFIG_DIR layout: each immediate subdir becomes an account labeled by\n# the subdir name (shown as a TUI tab and selectable with --account <label>),\n# refreshed independently. Credentials live in .credentials.json on Linux and\n# may live only in a config-dir-scoped Keychain item on macOS. Populate it with:\n# CLAUDE_CONFIG_DIR=~/.config/ai-usagebar/accounts/work claude # sign in\n# Merged with the explicit [[anthropic.accounts]] entries below (explicit wins).\n# accounts_dir = \"~/.config/ai-usagebar/accounts\"\n# Hide the default (unnamed) Claude tab when you manage every account explicitly\n# above — otherwise you get an extra tab for the ambient ~/.claude / Keychain\n# login. Default true; ignored when there are no named accounts.\n# show_default_account = false\n# Where the Claude *Desktop app*'s saved accounts live, for `account switch`.\n# Defaults to the store claude-acc creates (https://github.com/ohmaseclaro/claude-acc);\n# unrelated to accounts_dir above, which is the `claude` CLI's own accounts.\n# desktop_profiles_dir = \"~/.claude-acc/profiles\"\n# Or list accounts explicitly:\n# [[anthropic.accounts]]\n# label = \"work\"\n# credentials_path = \"~/.config/ai-usagebar/accounts/work/.credentials.json\"\n\n# Anthropic (API) — unlike the balance vendors above (which show your REMAINING\n# credit), Anthropic does not expose the prepaid credit balance over any API\n# (Console dashboard only), so this reports your month-to-date SPEND (consumed)\n# instead — for your API/Console account, which is separate from the Claude Code\n# OAuth login above.\n#\n# NOTE: the cost API does NOT report Priority Tier usage, so if your\n# organization is on Priority Tier the figure shown here is LOWER than your\n# real total spend. See\n# https://platform.claude.com/docs/en/manage-claude/usage-cost-api\n#\n# Opt-in (disabled until you set a key). Requires a Console *Admin key*\n# (`sk-ant-admin01-...`, sent as the `x-api-key` header), distinct from an\n# inference key. Admin keys exist only for ORGANIZATION accounts: set one up\n# first (Console > Settings > Organization) — the .../settings/admin-keys page\n# 404s for individual accounts.\n[anthropic_api]\nenabled = false\napi_key_env = \"ANTHROPIC_ADMIN_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-ant-admin01-...\" # fallback; chmod 600 the file if you use it\n# Positive, finite monthly USD limit for the spend-vs-limit % display (the API\n# doesn't expose a limit). With it: \"$1.34 / $1000 · 0%\". Without it:\n# \"$1.34/mo\".\n# monthly_limit = 1000\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# RESERVED — setting this does nothing today. It names the env var a future\n# API-key-only path would read (admin key → `/v1/organization/costs`). No code\n# consumes it: OpenAI usage comes only from Codex OAuth. Left commented out so\n# it isn't mistaken for a working alternative to `codex login`.\n# admin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n[kimi]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Shows Kimi Code subscription quota (weekly + 5h rolling window) from the\n# undocumented, community-confirmed api.kimi.com/coding/v1/usages endpoint.\nenabled = false\napi_key_env = \"KIMI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n# --- Account-balance vendors -------------------------------------------------\n# These report your REMAINING credit as money, rather than plan usage as a %.\n# All are opt-in and never fetch until enabled with a key present.\n\n[kilo]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the undocumented api.kilo.ai/api/profile/balance\n# endpoint (used internally by the Kilo Code extension).\nenabled = false\napi_key_env = \"KILO_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Scopes the balance to a team via the x-kilocode-organizationid header.\n# Omit for the personal balance. Switching this refetches rather than reusing\n# the previous account's cached figure.\n# organization_id = \"org_...\"\n\n[novita]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the documented\n# api.novita.ai/openapi/v1/billing/balance/detail endpoint.\nenabled = false\napi_key_env = \"NOVITA_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n\n[moonshot]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Account balance from the documented /v1/users/me/balance endpoint.\nenabled = false\napi_key_env = \"MOONSHOT_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n# Region picks BOTH the host and the currency, so switching it refetches\n# rather than showing the other region's figure:\n# global -> api.moonshot.ai (USD) | cn -> api.moonshot.cn (CNY)\n# region = \"global\"\n\n[grok]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Prepaid credit from the documented xAI Management API\n# management-api.x.ai/v1/billing/teams/{team}/prepaid/balance.\n#\n# This is the *Management* key (xAI Console > Management keys), NOT the\n# inference key you use for chat completions.\nenabled = false\napi_key_env = \"XAI_MANAGEMENT_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# The balance is per-team. A TEAM-scoped management key identifies its team on\n# its own; an ORGANIZATION-scoped key cannot (its scopeId is an organization,\n# not a team), so set the team explicitly here. Without it, an org-scoped key\n# reports an error saying so instead of querying the wrong team.\n# team_id = \"...\"\n\n# Google Antigravity. No credentials: usage is read from whichever local\n# Antigravity product is running (Antigravity 2.0, the `agy` CLI, or the IDE) —\n# all three share one account-wide quota. Set ANTIGRAVITY_LS_ADDRESS to\n# \"host:port\" only if auto-discovery of that local server ever fails.\n[antigravity]\nenabled = false\n\n# Cursor. No API key: usage is read from the session token the Cursor IDE\n# already wrote to its own local state database after you signed in there —\n# no separate login step for ai-usagebar. Shows this billing cycle's two\n# included-usage pools — \"Cursor Models\" (Auto + Composer) and \"Other Models\"\n# (named / API) — from the undocumented cursor.com/api/usage-summary endpoint\n# (the same one the Cursor dashboard itself calls).\n[cursor]\n# Disabled by default — enable once you've signed in to the Cursor IDE at\n# least once (it needs no key from you, but it does need that one-time login\n# to exist).\nenabled = false\n# Override Cursor's local state database path. Leave commented out to use the\n# platform default:\n# Linux: ~/.config/Cursor/User/globalStorage/state.vscdb\n# macOS: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb\n# Windows: %APPDATA%\\Cursor\\User\\globalStorage\\state.vscdb\n# Set this if you use a portable Cursor install or a renamed profile dir.\n# db_path = \"/home/you/.config/Cursor/User/globalStorage/state.vscdb\"\n"}, "c1dd1b744b32db9c473e108d6eca14a7bd0a2bf3": {"t": 1785750120.7222416, "n": 10931, "h": "88d925cf41047876", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek | kimi\n# When unset, the TUI opens on the Overview (all vendors at once); set this to\n# open directly on a vendor's tab instead.\n# primary = \"anthropic\"\n# Which vendors the Overview lists (the TUI's first tab / the macOS menu-bar's\n# top section), in this order. Omit for every enabled vendor.\n# overview_vendors = [\"anthropic\", \"cursor\", \"openai\"]\n# Presentation of vendor tab navigation: sidebar (vertical box) | navbar (top strip) | none (hidden).\n# Default sidebar (falls back to navbar on narrow terminals).\n# vendor_box = \"sidebar\"\n\n# Optional, TUI-only monitor for local Claude Code session context. When\n# enabled, press `c` in ai-usagebar-tui. The scanner reads only bounded tails\n# below ~/.claude/projects, skips subagent transcripts and never follows\n# discovered symlinks. Nothing is read while this remains disabled.\n[context]\nenabled = false\n# Where the view docks when opened: full (own screen) | split (beside the\n# dashboard) | bottom. `v` cycles it while open; this is the starting value.\nlayout = \"full\"\n# projects_path = \"~/.claude/projects\" # default\n# Set a fallback only when it matches the sessions you use. Without a known\n# denominator the overlay shows raw input tokens rather than inventing a %.\n# context_window_tokens = 200000\n# Exact per-model values take precedence, which is useful when 200K and 1M\n# sessions coexist. Keys must match the model id stored in the transcript.\n# [context.model_context_window_tokens]\n# \"claude-opus-4-6\" = 1000000\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n# Auto-discover extra accounts from a directory, in Claude Code's own\n# CLAUDE_CONFIG_DIR layout: each immediate subdir becomes an account labeled by\n# the subdir name (shown as a TUI tab and selectable with --account <label>),\n# refreshed independently. Credentials live in .credentials.json on Linux and\n# may live only in a config-dir-scoped Keychain item on macOS. Populate it with:\n# CLAUDE_CONFIG_DIR=~/.config/ai-usagebar/accounts/work claude # sign in\n# Merged with the explicit [[anthropic.accounts]] entries below (explicit wins).\n# accounts_dir = \"~/.config/ai-usagebar/accounts\"\n# Hide the default (unnamed) Claude tab when you manage every account explicitly\n# above — otherwise you get an extra tab for the ambient ~/.claude / Keychain\n# login. Default true; ignored when there are no named accounts.\n# show_default_account = false\n# Or list accounts explicitly:\n# [[anthropic.accounts]]\n# label = \"work\"\n# credentials_path = \"~/.config/ai-usagebar/accounts/work/.credentials.json\"\n\n# Anthropic (API) — unlike the balance vendors above (which show your REMAINING\n# credit), Anthropic does not expose the prepaid credit balance over any API\n# (Console dashboard only), so this reports your month-to-date SPEND (consumed)\n# instead — for your API/Console account, which is separate from the Claude Code\n# OAuth login above.\n#\n# NOTE: the cost API does NOT report Priority Tier usage, so if your\n# organization is on Priority Tier the figure shown here is LOWER than your\n# real total spend. See\n# https://platform.claude.com/docs/en/manage-claude/usage-cost-api\n#\n# Opt-in (disabled until you set a key). Requires a Console *Admin key*\n# (`sk-ant-admin01-...`, sent as the `x-api-key` header), distinct from an\n# inference key. Admin keys exist only for ORGANIZATION accounts: set one up\n# first (Console > Settings > Organization) — the .../settings/admin-keys page\n# 404s for individual accounts.\n[anthropic_api]\nenabled = false\napi_key_env = \"ANTHROPIC_ADMIN_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-ant-admin01-...\" # fallback; chmod 600 the file if you use it\n# Positive, finite monthly USD limit for the spend-vs-limit % display (the API\n# doesn't expose a limit). With it: \"$1.34 / $1000 · 0%\". Without it:\n# \"$1.34/mo\".\n# monthly_limit = 1000\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# RESERVED — setting this does nothing today. It names the env var a future\n# API-key-only path would read (admin key → `/v1/organization/costs`). No code\n# consumes it: OpenAI usage comes only from Codex OAuth. Left commented out so\n# it isn't mistaken for a working alternative to `codex login`.\n# admin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n[kimi]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Shows Kimi Code subscription quota (weekly + 5h rolling window) from the\n# undocumented, community-confirmed api.kimi.com/coding/v1/usages endpoint.\nenabled = false\napi_key_env = \"KIMI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n# --- Account-balance vendors -------------------------------------------------\n# These report your REMAINING credit as money, rather than plan usage as a %.\n# All are opt-in and never fetch until enabled with a key present.\n\n[kilo]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the undocumented api.kilo.ai/api/profile/balance\n# endpoint (used internally by the Kilo Code extension).\nenabled = false\napi_key_env = \"KILO_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Scopes the balance to a team via the x-kilocode-organizationid header.\n# Omit for the personal balance. Switching this refetches rather than reusing\n# the previous account's cached figure.\n# organization_id = \"org_...\"\n\n[novita]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the documented\n# api.novita.ai/openapi/v1/billing/balance/detail endpoint.\nenabled = false\napi_key_env = \"NOVITA_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n\n[moonshot]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Account balance from the documented /v1/users/me/balance endpoint.\nenabled = false\napi_key_env = \"MOONSHOT_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n# Region picks BOTH the host and the currency, so switching it refetches\n# rather than showing the other region's figure:\n# global -> api.moonshot.ai (USD) | cn -> api.moonshot.cn (CNY)\n# region = \"global\"\n\n[grok]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Prepaid credit from the documented xAI Management API\n# management-api.x.ai/v1/billing/teams/{team}/prepaid/balance.\n#\n# This is the *Management* key (xAI Console > Management keys), NOT the\n# inference key you use for chat completions.\nenabled = false\napi_key_env = \"XAI_MANAGEMENT_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# The balance is per-team. A TEAM-scoped management key identifies its team on\n# its own; an ORGANIZATION-scoped key cannot (its scopeId is an organization,\n# not a team), so set the team explicitly here. Without it, an org-scoped key\n# reports an error saying so instead of querying the wrong team.\n# team_id = \"...\"\n\n# Google Antigravity. No credentials: usage is read from whichever local\n# Antigravity product is running (Antigravity 2.0, the `agy` CLI, or the IDE) —\n# all three share one account-wide quota. Set ANTIGRAVITY_LS_ADDRESS to\n# \"host:port\" only if auto-discovery of that local server ever fails.\n[antigravity]\nenabled = false\n\n# Cursor. No API key: usage is read from the session token the Cursor IDE\n# already wrote to its own local state database after you signed in there —\n# no separate login step for ai-usagebar. Shows this billing cycle's two\n# included-usage pools — \"Cursor Models\" (Auto + Composer) and \"Other Models\"\n# (named / API) — from the undocumented cursor.com/api/usage-summary endpoint\n# (the same one the Cursor dashboard itself calls).\n[cursor]\n# Disabled by default — enable once you've signed in to the Cursor IDE at\n# least once (it needs no key from you, but it does need that one-time login\n# to exist).\nenabled = false\n# Override Cursor's local state database path. Leave commented out to use the\n# platform default:\n# Linux: ~/.config/Cursor/User/globalStorage/state.vscdb\n# macOS: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb\n# Windows: %APPDATA%\\Cursor\\User\\globalStorage\\state.vscdb\n# Set this if you use a portable Cursor install or a renamed profile dir.\n# db_path = \"/home/you/.config/Cursor/User/globalStorage/state.vscdb\"\n"}, "1585ca90e6b5549298da827fa8a254d78c5b6eb1": {"t": 1785750121.9516327, "n": 11579, "h": "9d1ffebd1823a06e", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek | kimi\n# When unset, the TUI opens on the Overview (all vendors at once); set this to\n# open directly on a vendor's tab instead.\n# primary = \"anthropic\"\n# Which vendors the Overview lists (the TUI's first tab / the macOS menu-bar's\n# top section), in this order. Omit for every enabled vendor.\n# overview_vendors = [\"anthropic\", \"cursor\", \"openai\"]\n\n# Optional, TUI-only monitor for local Claude Code session context. When\n# enabled, press `c` in ai-usagebar-tui. The scanner reads only bounded tails\n# below ~/.claude/projects, skips subagent transcripts and never follows\n# discovered symlinks. Nothing is read while this remains disabled.\n[context]\nenabled = false\n# Where the view docks when opened: full (own screen) | split (beside the\n# dashboard) | bottom. `v` cycles it while open; this is the starting value.\nlayout = \"full\"\n# projects_path = \"~/.claude/projects\" # default\n# Set a fallback only when it matches the sessions you use. Without a known\n# denominator the overlay shows raw input tokens rather than inventing a %.\n# context_window_tokens = 200000\n# Exact per-model values take precedence, which is useful when 200K and 1M\n# sessions coexist. Keys must match the model id stored in the transcript.\n# [context.model_context_window_tokens]\n# \"claude-opus-4-6\" = 1000000\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n# Auto-discover extra accounts from a directory, in Claude Code's own\n# CLAUDE_CONFIG_DIR layout: each immediate subdir becomes an account labeled by\n# the subdir name (shown as a TUI tab and selectable with --account <label>),\n# refreshed independently. Credentials live in .credentials.json on Linux and\n# may live only in a config-dir-scoped Keychain item on macOS. Populate it with:\n# CLAUDE_CONFIG_DIR=~/.config/ai-usagebar/accounts/work claude # sign in\n# Merged with the explicit [[anthropic.accounts]] entries below (explicit wins).\n# accounts_dir = \"~/.config/ai-usagebar/accounts\"\n# Hide the default (unnamed) Claude tab when you manage every account explicitly\n# above — otherwise you get an extra tab for the ambient ~/.claude / Keychain\n# login. Default true; ignored when there are no named accounts.\n# show_default_account = false\n# Or list accounts explicitly:\n# [[anthropic.accounts]]\n# label = \"work\"\n# credentials_path = \"~/.config/ai-usagebar/accounts/work/.credentials.json\"\n\n# Anthropic (API) — unlike the balance vendors above (which show your REMAINING\n# credit), Anthropic does not expose the prepaid credit balance over any API\n# (Console dashboard only), so this reports your month-to-date SPEND (consumed)\n# instead — for your API/Console account, which is separate from the Claude Code\n# OAuth login above.\n#\n# NOTE: the cost API does NOT report Priority Tier usage, so if your\n# organization is on Priority Tier the figure shown here is LOWER than your\n# real total spend. See\n# https://platform.claude.com/docs/en/manage-claude/usage-cost-api\n#\n# Opt-in (disabled until you set a key). Requires a Console *Admin key*\n# (`sk-ant-admin01-...`, sent as the `x-api-key` header), distinct from an\n# inference key. Admin keys exist only for ORGANIZATION accounts: set one up\n# first (Console > Settings > Organization) — the .../settings/admin-keys page\n# 404s for individual accounts.\n[anthropic_api]\nenabled = false\napi_key_env = \"ANTHROPIC_ADMIN_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-ant-admin01-...\" # fallback; chmod 600 the file if you use it\n# Positive, finite monthly USD limit for the spend-vs-limit % display (the API\n# doesn't expose a limit). With it: \"$1.34 / $1000 · 0%\". Without it:\n# \"$1.34/mo\".\n# monthly_limit = 1000\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# RESERVED — setting this does nothing today. It names the env var a future\n# API-key-only path would read (admin key → `/v1/organization/costs`). No code\n# consumes it: OpenAI usage comes only from Codex OAuth. Left commented out so\n# it isn't mistaken for a working alternative to `codex login`.\n# admin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n[kimi]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Shows Kimi Code subscription quota (weekly + 5h rolling window) from the\n# undocumented, community-confirmed api.kimi.com/coding/v1/usages endpoint.\nenabled = false\napi_key_env = \"KIMI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n# --- Account-balance vendors -------------------------------------------------\n# These report your REMAINING credit as money, rather than plan usage as a %.\n# All are opt-in and never fetch until enabled with a key present.\n\n[kilo]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the undocumented api.kilo.ai/api/profile/balance\n# endpoint (used internally by the Kilo Code extension).\nenabled = false\napi_key_env = \"KILO_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Scopes the balance to a team via the x-kilocode-organizationid header.\n# Omit for the personal balance. Switching this refetches rather than reusing\n# the previous account's cached figure.\n# organization_id = \"org_...\"\n\n[novita]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the documented\n# api.novita.ai/openapi/v1/billing/balance/detail endpoint.\nenabled = false\napi_key_env = \"NOVITA_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n\n[moonshot]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Account balance from the documented /v1/users/me/balance endpoint.\nenabled = false\napi_key_env = \"MOONSHOT_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n# Region picks BOTH the host and the currency, so switching it refetches\n# rather than showing the other region's figure:\n# global -> api.moonshot.ai (USD) | cn -> api.moonshot.cn (CNY)\n# region = \"global\"\n\n[grok]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Prepaid credit from the documented xAI Management API\n# management-api.x.ai/v1/billing/teams/{team}/prepaid/balance.\n#\n# This is the *Management* key (xAI Console > Management keys), NOT the\n# inference key you use for chat completions.\nenabled = false\napi_key_env = \"XAI_MANAGEMENT_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# The balance is per-team. A TEAM-scoped management key identifies its team on\n# its own; an ORGANIZATION-scoped key cannot (its scopeId is an organization,\n# not a team), so set the team explicitly here. Without it, an org-scoped key\n# reports an error saying so instead of querying the wrong team.\n# team_id = \"...\"\n\n# Google Antigravity. No credentials: usage is read from whichever local\n# Antigravity product is running (Antigravity 2.0, the `agy` CLI, or the IDE) —\n# all three share one account-wide quota. Set ANTIGRAVITY_LS_ADDRESS to\n# \"host:port\" only if auto-discovery of that local server ever fails.\n[antigravity]\nenabled = false\n\n# Cursor. No API key: usage is read from the session token the Cursor IDE\n# already wrote to its own local state database after you signed in there —\n# no separate login step for ai-usagebar. Shows this billing cycle's two\n# included-usage pools — \"Cursor Models\" (Auto + Composer) and \"Other Models\"\n# (named / API) — from the undocumented cursor.com/api/usage-summary endpoint\n# (the same one the Cursor dashboard itself calls).\n[cursor]\n# Disabled by default — enable once you've signed in to the Cursor IDE at\n# least once (it needs no key from you, but it does need that one-time login\n# to exist).\nenabled = false\n# Override Cursor's local state database path. Leave commented out to use the\n# platform default:\n# Linux: ~/.config/Cursor/User/globalStorage/state.vscdb\n# macOS: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb\n# Windows: %APPDATA%\\Cursor\\User\\globalStorage\\state.vscdb\n# Set this if you use a portable Cursor install or a renamed profile dir.\n# db_path = \"/home/you/.config/Cursor/User/globalStorage/state.vscdb\"\n\n[minimax]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Token Plan subscription quota (rolling interval window + weekly) from the\n# undocumented /v1/token_plan/remains endpoint. Needs the Token Plan\n# SUBSCRIPTION key — a pay-as-you-go key has no plan quota to report.\nenabled = false\napi_key_env = \"MINIMAX_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Region picks the INSTANCE, and the two issue separate keys — pointing this\n# at the wrong one reads as an invalid key, not an empty plan:\n# global -> api.minimax.io | cn -> api.minimaxi.com\n# region = \"global\"\n"}, "9239b68aaed3c53b294fa063ab96d98bd4932329": {"t": 1785750123.4017556, "n": 10742, "h": "4bd6d20be1840327", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek | kimi\n# When unset, the TUI opens on the Overview (all vendors at once); set this to\n# open directly on a vendor's tab instead.\n# primary = \"anthropic\"\n# Which vendors the Overview lists (the TUI's first tab / the macOS menu-bar's\n# top section), in this order. Omit for every enabled vendor.\n# overview_vendors = [\"anthropic\", \"cursor\", \"openai\"]\n\n# Optional, TUI-only monitor for local Claude Code session context. When\n# enabled, press `c` in ai-usagebar-tui. The scanner reads only bounded tails\n# below ~/.claude/projects, skips subagent transcripts and never follows\n# discovered symlinks. Nothing is read while this remains disabled.\n[context]\nenabled = false\n# Where the view docks when opened: full (own screen) | split (beside the\n# dashboard) | bottom. `v` cycles it while open; this is the starting value.\nlayout = \"full\"\n# projects_path = \"~/.claude/projects\" # default\n# Set a fallback only when it matches the sessions you use. Without a known\n# denominator the overlay shows raw input tokens rather than inventing a %.\n# context_window_tokens = 200000\n# Exact per-model values take precedence, which is useful when 200K and 1M\n# sessions coexist. Keys must match the model id stored in the transcript.\n# [context.model_context_window_tokens]\n# \"claude-opus-4-6\" = 1000000\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n# Auto-discover extra accounts from a directory, in Claude Code's own\n# CLAUDE_CONFIG_DIR layout: each immediate subdir becomes an account labeled by\n# the subdir name (shown as a TUI tab and selectable with --account <label>),\n# refreshed independently. Credentials live in .credentials.json on Linux and\n# may live only in a config-dir-scoped Keychain item on macOS. Populate it with:\n# CLAUDE_CONFIG_DIR=~/.config/ai-usagebar/accounts/work claude # sign in\n# Merged with the explicit [[anthropic.accounts]] entries below (explicit wins).\n# accounts_dir = \"~/.config/ai-usagebar/accounts\"\n# Hide the default (unnamed) Claude tab when you manage every account explicitly\n# above — otherwise you get an extra tab for the ambient ~/.claude / Keychain\n# login. Default true; ignored when there are no named accounts.\n# show_default_account = false\n# Or list accounts explicitly:\n# [[anthropic.accounts]]\n# label = \"work\"\n# credentials_path = \"~/.config/ai-usagebar/accounts/work/.credentials.json\"\n\n# Anthropic (API) — unlike the balance vendors above (which show your REMAINING\n# credit), Anthropic does not expose the prepaid credit balance over any API\n# (Console dashboard only), so this reports your month-to-date SPEND (consumed)\n# instead — for your API/Console account, which is separate from the Claude Code\n# OAuth login above.\n#\n# NOTE: the cost API does NOT report Priority Tier usage, so if your\n# organization is on Priority Tier the figure shown here is LOWER than your\n# real total spend. See\n# https://platform.claude.com/docs/en/manage-claude/usage-cost-api\n#\n# Opt-in (disabled until you set a key). Requires a Console *Admin key*\n# (`sk-ant-admin01-...`, sent as the `x-api-key` header), distinct from an\n# inference key. Admin keys exist only for ORGANIZATION accounts: set one up\n# first (Console > Settings > Organization) — the .../settings/admin-keys page\n# 404s for individual accounts.\n[anthropic_api]\nenabled = false\napi_key_env = \"ANTHROPIC_ADMIN_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-ant-admin01-...\" # fallback; chmod 600 the file if you use it\n# Positive, finite monthly USD limit for the spend-vs-limit % display (the API\n# doesn't expose a limit). With it: \"$1.34 / $1000 · 0%\". Without it:\n# \"$1.34/mo\".\n# monthly_limit = 1000\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# RESERVED — setting this does nothing today. It names the env var a future\n# API-key-only path would read (admin key → `/v1/organization/costs`). No code\n# consumes it: OpenAI usage comes only from Codex OAuth. Left commented out so\n# it isn't mistaken for a working alternative to `codex login`.\n# admin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n[kimi]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Shows Kimi Code subscription quota (weekly + 5h rolling window) from the\n# undocumented, community-confirmed api.kimi.com/coding/v1/usages endpoint.\nenabled = false\napi_key_env = \"KIMI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n# --- Account-balance vendors -------------------------------------------------\n# These report your REMAINING credit as money, rather than plan usage as a %.\n# All are opt-in and never fetch until enabled with a key present.\n\n[kilo]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the undocumented api.kilo.ai/api/profile/balance\n# endpoint (used internally by the Kilo Code extension).\nenabled = false\napi_key_env = \"KILO_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Scopes the balance to a team via the x-kilocode-organizationid header.\n# Omit for the personal balance. Switching this refetches rather than reusing\n# the previous account's cached figure.\n# organization_id = \"org_...\"\n\n[novita]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the documented\n# api.novita.ai/openapi/v1/billing/balance/detail endpoint.\nenabled = false\napi_key_env = \"NOVITA_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n\n[moonshot]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Account balance from the documented /v1/users/me/balance endpoint.\nenabled = false\napi_key_env = \"MOONSHOT_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n# Region picks BOTH the host and the currency, so switching it refetches\n# rather than showing the other region's figure:\n# global -> api.moonshot.ai (USD) | cn -> api.moonshot.cn (CNY)\n# region = \"global\"\n\n[grok]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Prepaid credit from the documented xAI Management API\n# management-api.x.ai/v1/billing/teams/{team}/prepaid/balance.\n#\n# This is the *Management* key (xAI Console > Management keys), NOT the\n# inference key you use for chat completions.\nenabled = false\napi_key_env = \"XAI_MANAGEMENT_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# The balance is per-team. A TEAM-scoped management key identifies its team on\n# its own; an ORGANIZATION-scoped key cannot (its scopeId is an organization,\n# not a team), so set the team explicitly here. Without it, an org-scoped key\n# reports an error saying so instead of querying the wrong team.\n# team_id = \"...\"\n\n# Google Antigravity. No credentials: usage is read from whichever local\n# Antigravity product is running (Antigravity 2.0, the `agy` CLI, or the IDE) —\n# all three share one account-wide quota. Set ANTIGRAVITY_LS_ADDRESS to\n# \"host:port\" only if auto-discovery of that local server ever fails.\n[antigravity]\nenabled = false\n\n# Cursor. No API key: usage is read from the session token the Cursor IDE\n# already wrote to its own local state database after you signed in there —\n# no separate login step for ai-usagebar. Shows this billing cycle's two\n# included-usage pools — \"Cursor Models\" (Auto + Composer) and \"Other Models\"\n# (named / API) — from the undocumented cursor.com/api/usage-summary endpoint\n# (the same one the Cursor dashboard itself calls).\n[cursor]\n# Disabled by default — enable once you've signed in to the Cursor IDE at\n# least once (it needs no key from you, but it does need that one-time login\n# to exist).\nenabled = false\n# Override Cursor's local state database path. Leave commented out to use the\n# platform default:\n# Linux: ~/.config/Cursor/User/globalStorage/state.vscdb\n# macOS: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb\n# Windows: %APPDATA%\\Cursor\\User\\globalStorage\\state.vscdb\n# Set this if you use a portable Cursor install or a renamed profile dir.\n# db_path = \"/home/you/.config/Cursor/User/globalStorage/state.vscdb\"\n"}, "a9fe4dad61bb5a86b9dcd27e1952c04c9ac70398": {"t": 1785750125.1881776, "n": 10652, "h": "2493a1720af3e644", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek | kimi\n# When unset, the TUI opens on the Overview (all vendors at once); set this to\n# open directly on a vendor's tab instead.\n# primary = \"anthropic\"\n# Which vendors the Overview lists (the TUI's first tab / the macOS menu-bar's\n# top section), in this order. Omit for every enabled vendor.\n# overview_vendors = [\"anthropic\", \"cursor\", \"openai\"]\n\n# Optional, TUI-only monitor for local Claude Code session context. When\n# enabled, press `c` in ai-usagebar-tui. The scanner reads only bounded tails\n# below ~/.claude/projects, skips subagent transcripts and never follows\n# discovered symlinks. Nothing is read while this remains disabled.\n[context]\nenabled = false\n# Where the view docks when opened: full (own screen) | split (beside the\n# dashboard) | bottom. `v` cycles it while open; this is the starting value.\nlayout = \"full\"\n# projects_path = \"~/.claude/projects\" # default\n# Set a fallback only when it matches the sessions you use. Without a known\n# denominator the overlay shows raw input tokens rather than inventing a %.\n# context_window_tokens = 200000\n# Exact per-model values take precedence, which is useful when 200K and 1M\n# sessions coexist. Keys must match the model id stored in the transcript.\n# [context.model_context_window_tokens]\n# \"claude-opus-4-6\" = 1000000\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n# Auto-discover extra accounts from a directory, in Claude Code's own\n# CLAUDE_CONFIG_DIR layout: each subdir holding a .credentials.json becomes an\n# account labeled by the subdir name (shown as a TUI tab and selectable with\n# --account <label>), refreshed independently. Populate it with, e.g.:\n# CLAUDE_CONFIG_DIR=~/.config/ai-usagebar/accounts/work claude # sign in\n# Merged with the explicit [[anthropic.accounts]] entries below (explicit wins).\n# accounts_dir = \"~/.config/ai-usagebar/accounts\"\n# Hide the default (unnamed) Claude tab when you manage every account explicitly\n# above — otherwise you get an extra tab for the ambient ~/.claude / Keychain\n# login. Default true; ignored when there are no named accounts.\n# show_default_account = false\n# Or list accounts explicitly:\n# [[anthropic.accounts]]\n# label = \"work\"\n# credentials_path = \"~/.config/ai-usagebar/accounts/work/.credentials.json\"\n\n# Anthropic (API) — unlike the balance vendors above (which show your REMAINING\n# credit), Anthropic does not expose the prepaid credit balance over any API\n# (Console dashboard only), so this reports your month-to-date SPEND (consumed)\n# instead — for your API/Console account, which is separate from the Claude Code\n# OAuth login above.\n#\n# NOTE: the cost API does NOT report Priority Tier usage, so if your\n# organization is on Priority Tier the figure shown here is LOWER than your\n# real total spend. See\n# https://platform.claude.com/docs/en/manage-claude/usage-cost-api\n#\n# Opt-in (disabled until you set a key). Requires a Console *Admin key*\n# (`sk-ant-admin01-...`, sent as the `x-api-key` header), distinct from an\n# inference key. Admin keys exist only for ORGANIZATION accounts: set one up\n# first (Console > Settings > Organization) — the .../settings/admin-keys page\n# 404s for individual accounts.\n[anthropic_api]\nenabled = false\napi_key_env = \"ANTHROPIC_ADMIN_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-ant-admin01-...\" # fallback; chmod 600 the file if you use it\n# Positive, finite monthly USD limit for the spend-vs-limit % display (the API\n# doesn't expose a limit). With it: \"$1.34 / $1000 · 0%\". Without it:\n# \"$1.34/mo\".\n# monthly_limit = 1000\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# RESERVED — setting this does nothing today. It names the env var a future\n# API-key-only path would read (admin key → `/v1/organization/costs`). No code\n# consumes it: OpenAI usage comes only from Codex OAuth. Left commented out so\n# it isn't mistaken for a working alternative to `codex login`.\n# admin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n[kimi]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Shows Kimi Code subscription quota (weekly + 5h rolling window) from the\n# undocumented, community-confirmed api.kimi.com/coding/v1/usages endpoint.\nenabled = false\napi_key_env = \"KIMI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n# --- Account-balance vendors -------------------------------------------------\n# These report your REMAINING credit as money, rather than plan usage as a %.\n# All are opt-in and never fetch until enabled with a key present.\n\n[kilo]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the undocumented api.kilo.ai/api/profile/balance\n# endpoint (used internally by the Kilo Code extension).\nenabled = false\napi_key_env = \"KILO_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Scopes the balance to a team via the x-kilocode-organizationid header.\n# Omit for the personal balance. Switching this refetches rather than reusing\n# the previous account's cached figure.\n# organization_id = \"org_...\"\n\n[novita]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the documented\n# api.novita.ai/openapi/v1/billing/balance/detail endpoint.\nenabled = false\napi_key_env = \"NOVITA_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n\n[moonshot]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Account balance from the documented /v1/users/me/balance endpoint.\nenabled = false\napi_key_env = \"MOONSHOT_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n# Region picks BOTH the host and the currency, so switching it refetches\n# rather than showing the other region's figure:\n# global -> api.moonshot.ai (USD) | cn -> api.moonshot.cn (CNY)\n# region = \"global\"\n\n[grok]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Prepaid credit from the documented xAI Management API\n# management-api.x.ai/v1/billing/teams/{team}/prepaid/balance.\n#\n# This is the *Management* key (xAI Console > Management keys), NOT the\n# inference key you use for chat completions.\nenabled = false\napi_key_env = \"XAI_MANAGEMENT_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# The balance is per-team. A TEAM-scoped management key identifies its team on\n# its own; an ORGANIZATION-scoped key cannot (its scopeId is an organization,\n# not a team), so set the team explicitly here. Without it, an org-scoped key\n# reports an error saying so instead of querying the wrong team.\n# team_id = \"...\"\n\n# Google Antigravity. No credentials: usage is read from whichever local\n# Antigravity product is running (Antigravity 2.0, the `agy` CLI, or the IDE) —\n# all three share one account-wide quota. Set ANTIGRAVITY_LS_ADDRESS to\n# \"host:port\" only if auto-discovery of that local server ever fails.\n[antigravity]\nenabled = false\n\n# Cursor. No API key: usage is read from the session token the Cursor IDE\n# already wrote to its own local state database after you signed in there —\n# no separate login step for ai-usagebar. Shows this billing cycle's two\n# included-usage pools — \"Cursor Models\" (Auto + Composer) and \"Other Models\"\n# (named / API) — from the undocumented cursor.com/api/usage-summary endpoint\n# (the same one the Cursor dashboard itself calls).\n[cursor]\n# Disabled by default — enable once you've signed in to the Cursor IDE at\n# least once (it needs no key from you, but it does need that one-time login\n# to exist).\nenabled = false\n# Override Cursor's local state database path. Leave commented out to use the\n# platform default:\n# Linux: ~/.config/Cursor/User/globalStorage/state.vscdb\n# macOS: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb\n# Windows: %APPDATA%\\Cursor\\User\\globalStorage\\state.vscdb\n# Set this if you use a portable Cursor install or a renamed profile dir.\n# db_path = \"/home/you/.config/Cursor/User/globalStorage/state.vscdb\"\n"}, "a1d228b763f26173acdbb0c6a1f6a245234d1268": {"t": 1785750126.2772703, "n": 9742, "h": "6459f6f7f7eaafce", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek | kimi\n# When unset, the TUI opens on the Overview (all vendors at once); set this to\n# open directly on a vendor's tab instead.\n# primary = \"anthropic\"\n# Which vendors the Overview lists (the TUI's first tab / the macOS menu-bar's\n# top section), in this order. Omit for every enabled vendor.\n# overview_vendors = [\"anthropic\", \"cursor\", \"openai\"]\n\n# Optional, TUI-only monitor for local Claude Code session context. When\n# enabled, press `c` in ai-usagebar-tui. The scanner reads only bounded tails\n# below ~/.claude/projects, skips subagent transcripts and never follows\n# discovered symlinks. Nothing is read while this remains disabled.\n[context]\nenabled = false\n# Where the view docks when opened: full (own screen) | split (beside the\n# dashboard) | bottom. `v` cycles it while open; this is the starting value.\nlayout = \"full\"\n# projects_path = \"~/.claude/projects\" # default\n# Set a fallback only when it matches the sessions you use. Without a known\n# denominator the overlay shows raw input tokens rather than inventing a %.\n# context_window_tokens = 200000\n# Exact per-model values take precedence, which is useful when 200K and 1M\n# sessions coexist. Keys must match the model id stored in the transcript.\n# [context.model_context_window_tokens]\n# \"claude-opus-4-6\" = 1000000\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n\n# Anthropic (API) — unlike the balance vendors above (which show your REMAINING\n# credit), Anthropic does not expose the prepaid credit balance over any API\n# (Console dashboard only), so this reports your month-to-date SPEND (consumed)\n# instead — for your API/Console account, which is separate from the Claude Code\n# OAuth login above.\n#\n# NOTE: the cost API does NOT report Priority Tier usage, so if your\n# organization is on Priority Tier the figure shown here is LOWER than your\n# real total spend. See\n# https://platform.claude.com/docs/en/manage-claude/usage-cost-api\n#\n# Opt-in (disabled until you set a key). Requires a Console *Admin key*\n# (`sk-ant-admin01-...`, sent as the `x-api-key` header), distinct from an\n# inference key. Admin keys exist only for ORGANIZATION accounts: set one up\n# first (Console > Settings > Organization) — the .../settings/admin-keys page\n# 404s for individual accounts.\n[anthropic_api]\nenabled = false\napi_key_env = \"ANTHROPIC_ADMIN_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-ant-admin01-...\" # fallback; chmod 600 the file if you use it\n# Positive, finite monthly USD limit for the spend-vs-limit % display (the API\n# doesn't expose a limit). With it: \"$1.34 / $1000 · 0%\". Without it:\n# \"$1.34/mo\".\n# monthly_limit = 1000\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# RESERVED — setting this does nothing today. It names the env var a future\n# API-key-only path would read (admin key → `/v1/organization/costs`). No code\n# consumes it: OpenAI usage comes only from Codex OAuth. Left commented out so\n# it isn't mistaken for a working alternative to `codex login`.\n# admin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n[kimi]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Shows Kimi Code subscription quota (weekly + 5h rolling window) from the\n# undocumented, community-confirmed api.kimi.com/coding/v1/usages endpoint.\nenabled = false\napi_key_env = \"KIMI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n# --- Account-balance vendors -------------------------------------------------\n# These report your REMAINING credit as money, rather than plan usage as a %.\n# All are opt-in and never fetch until enabled with a key present.\n\n[kilo]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the undocumented api.kilo.ai/api/profile/balance\n# endpoint (used internally by the Kilo Code extension).\nenabled = false\napi_key_env = \"KILO_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Scopes the balance to a team via the x-kilocode-organizationid header.\n# Omit for the personal balance. Switching this refetches rather than reusing\n# the previous account's cached figure.\n# organization_id = \"org_...\"\n\n[novita]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the documented\n# api.novita.ai/openapi/v1/billing/balance/detail endpoint.\nenabled = false\napi_key_env = \"NOVITA_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n\n[moonshot]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Account balance from the documented /v1/users/me/balance endpoint.\nenabled = false\napi_key_env = \"MOONSHOT_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n# Region picks BOTH the host and the currency, so switching it refetches\n# rather than showing the other region's figure:\n# global -> api.moonshot.ai (USD) | cn -> api.moonshot.cn (CNY)\n# region = \"global\"\n\n[grok]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Prepaid credit from the documented xAI Management API\n# management-api.x.ai/v1/billing/teams/{team}/prepaid/balance.\n#\n# This is the *Management* key (xAI Console > Management keys), NOT the\n# inference key you use for chat completions.\nenabled = false\napi_key_env = \"XAI_MANAGEMENT_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# The balance is per-team. A TEAM-scoped management key identifies its team on\n# its own; an ORGANIZATION-scoped key cannot (its scopeId is an organization,\n# not a team), so set the team explicitly here. Without it, an org-scoped key\n# reports an error saying so instead of querying the wrong team.\n# team_id = \"...\"\n\n# Google Antigravity. No credentials: usage is read from whichever local\n# Antigravity product is running (Antigravity 2.0, the `agy` CLI, or the IDE) —\n# all three share one account-wide quota. Set ANTIGRAVITY_LS_ADDRESS to\n# \"host:port\" only if auto-discovery of that local server ever fails.\n[antigravity]\nenabled = false\n\n# Cursor. No API key: usage is read from the session token the Cursor IDE\n# already wrote to its own local state database after you signed in there —\n# no separate login step for ai-usagebar. Shows this billing cycle's two\n# included-usage pools — \"Cursor Models\" (Auto + Composer) and \"Other Models\"\n# (named / API) — from the undocumented cursor.com/api/usage-summary endpoint\n# (the same one the Cursor dashboard itself calls).\n[cursor]\n# Disabled by default — enable once you've signed in to the Cursor IDE at\n# least once (it needs no key from you, but it does need that one-time login\n# to exist).\nenabled = false\n# Override Cursor's local state database path. Leave commented out to use the\n# platform default:\n# Linux: ~/.config/Cursor/User/globalStorage/state.vscdb\n# macOS: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb\n# Windows: %APPDATA%\\Cursor\\User\\globalStorage\\state.vscdb\n# Set this if you use a portable Cursor install or a renamed profile dir.\n# db_path = \"/home/you/.config/Cursor/User/globalStorage/state.vscdb\"\n"}, "38b5a70d3ca7cddada3062c0c4f26c95c47a0f0d": {"t": 1785750128.0108848, "n": 9424, "h": "8fa5af934770c21c", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek | kimi\n# primary = \"anthropic\"\n\n# Optional, TUI-only monitor for local Claude Code session context. When\n# enabled, press `c` in ai-usagebar-tui. The scanner reads only bounded tails\n# below ~/.claude/projects, skips subagent transcripts and never follows\n# discovered symlinks. Nothing is read while this remains disabled.\n[context]\nenabled = false\n# Where the view docks when opened: full (own screen) | split (beside the\n# dashboard) | bottom. `v` cycles it while open; this is the starting value.\nlayout = \"full\"\n# projects_path = \"~/.claude/projects\" # default\n# Set a fallback only when it matches the sessions you use. Without a known\n# denominator the overlay shows raw input tokens rather than inventing a %.\n# context_window_tokens = 200000\n# Exact per-model values take precedence, which is useful when 200K and 1M\n# sessions coexist. Keys must match the model id stored in the transcript.\n# [context.model_context_window_tokens]\n# \"claude-opus-4-6\" = 1000000\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n\n# Anthropic (API) — unlike the balance vendors above (which show your REMAINING\n# credit), Anthropic does not expose the prepaid credit balance over any API\n# (Console dashboard only), so this reports your month-to-date SPEND (consumed)\n# instead — for your API/Console account, which is separate from the Claude Code\n# OAuth login above.\n#\n# NOTE: the cost API does NOT report Priority Tier usage, so if your\n# organization is on Priority Tier the figure shown here is LOWER than your\n# real total spend. See\n# https://platform.claude.com/docs/en/manage-claude/usage-cost-api\n#\n# Opt-in (disabled until you set a key). Requires a Console *Admin key*\n# (`sk-ant-admin01-...`, sent as the `x-api-key` header), distinct from an\n# inference key. Admin keys exist only for ORGANIZATION accounts: set one up\n# first (Console > Settings > Organization) — the .../settings/admin-keys page\n# 404s for individual accounts.\n[anthropic_api]\nenabled = false\napi_key_env = \"ANTHROPIC_ADMIN_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-ant-admin01-...\" # fallback; chmod 600 the file if you use it\n# Positive, finite monthly USD limit for the spend-vs-limit % display (the API\n# doesn't expose a limit). With it: \"$1.34 / $1000 · 0%\". Without it:\n# \"$1.34/mo\".\n# monthly_limit = 1000\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# RESERVED — setting this does nothing today. It names the env var a future\n# API-key-only path would read (admin key → `/v1/organization/costs`). No code\n# consumes it: OpenAI usage comes only from Codex OAuth. Left commented out so\n# it isn't mistaken for a working alternative to `codex login`.\n# admin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n[kimi]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Shows Kimi Code subscription quota (weekly + 5h rolling window) from the\n# undocumented, community-confirmed api.kimi.com/coding/v1/usages endpoint.\nenabled = false\napi_key_env = \"KIMI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n# --- Account-balance vendors -------------------------------------------------\n# These report your REMAINING credit as money, rather than plan usage as a %.\n# All are opt-in and never fetch until enabled with a key present.\n\n[kilo]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the undocumented api.kilo.ai/api/profile/balance\n# endpoint (used internally by the Kilo Code extension).\nenabled = false\napi_key_env = \"KILO_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Scopes the balance to a team via the x-kilocode-organizationid header.\n# Omit for the personal balance. Switching this refetches rather than reusing\n# the previous account's cached figure.\n# organization_id = \"org_...\"\n\n[novita]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the documented\n# api.novita.ai/openapi/v1/billing/balance/detail endpoint.\nenabled = false\napi_key_env = \"NOVITA_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n\n[moonshot]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Account balance from the documented /v1/users/me/balance endpoint.\nenabled = false\napi_key_env = \"MOONSHOT_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n# Region picks BOTH the host and the currency, so switching it refetches\n# rather than showing the other region's figure:\n# global -> api.moonshot.ai (USD) | cn -> api.moonshot.cn (CNY)\n# region = \"global\"\n\n[grok]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Prepaid credit from the documented xAI Management API\n# management-api.x.ai/v1/billing/teams/{team}/prepaid/balance.\n#\n# This is the *Management* key (xAI Console > Management keys), NOT the\n# inference key you use for chat completions.\nenabled = false\napi_key_env = \"XAI_MANAGEMENT_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# The balance is per-team. A TEAM-scoped management key identifies its team on\n# its own; an ORGANIZATION-scoped key cannot (its scopeId is an organization,\n# not a team), so set the team explicitly here. Without it, an org-scoped key\n# reports an error saying so instead of querying the wrong team.\n# team_id = \"...\"\n\n# Google Antigravity. No credentials: usage is read from whichever local\n# Antigravity product is running (Antigravity 2.0, the `agy` CLI, or the IDE) —\n# all three share one account-wide quota. Set ANTIGRAVITY_LS_ADDRESS to\n# \"host:port\" only if auto-discovery of that local server ever fails.\n[antigravity]\nenabled = false\n\n# Cursor. No API key: usage is read from the session token the Cursor IDE\n# already wrote to its own local state database after you signed in there —\n# no separate login step for ai-usagebar. Shows this billing cycle's two\n# included-usage pools — \"Cursor Models\" (Auto + Composer) and \"Other Models\"\n# (named / API) — from the undocumented cursor.com/api/usage-summary endpoint\n# (the same one the Cursor dashboard itself calls).\n[cursor]\n# Disabled by default — enable once you've signed in to the Cursor IDE at\n# least once (it needs no key from you, but it does need that one-time login\n# to exist).\nenabled = false\n# Override Cursor's local state database path. Leave commented out to use the\n# platform default:\n# Linux: ~/.config/Cursor/User/globalStorage/state.vscdb\n# macOS: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb\n# Windows: %APPDATA%\\Cursor\\User\\globalStorage\\state.vscdb\n# Set this if you use a portable Cursor install or a renamed profile dir.\n# db_path = \"/home/you/.config/Cursor/User/globalStorage/state.vscdb\"\n"}, "f2a2d267dfad996276fe1e2dfa1418d2bbba2014": {"t": 1785750129.5716138, "n": 9268, "h": "65b99b57009f6a62", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek | kimi\n# primary = \"anthropic\"\n\n# Optional, TUI-only monitor for local Claude Code session context. When\n# enabled, press `c` in ai-usagebar-tui. The scanner reads only bounded tails\n# below ~/.claude/projects, skips subagent transcripts and never follows\n# discovered symlinks. Nothing is read while this remains disabled.\n[context]\nenabled = false\n# Where the view docks when opened: full (own screen) | split (beside the\n# dashboard) | bottom. `v` cycles it while open; this is the starting value.\nlayout = \"full\"\n# projects_path = \"~/.claude/projects\" # default\n# Set a fallback only when it matches the sessions you use. Without a known\n# denominator the overlay shows raw input tokens rather than inventing a %.\n# context_window_tokens = 200000\n# Exact per-model values take precedence, which is useful when 200K and 1M\n# sessions coexist. Keys must match the model id stored in the transcript.\n# [context.model_context_window_tokens]\n# \"claude-opus-4-6\" = 1000000\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n# Auto-discover extra accounts from a directory, in Claude Code's own\n# CLAUDE_CONFIG_DIR layout: each subdir holding a .credentials.json becomes an\n# account labeled by the subdir name (shown as a TUI tab and selectable with\n# --account <label>), refreshed independently. Populate it with, e.g.:\n# CLAUDE_CONFIG_DIR=~/.config/ai-usagebar/accounts/work claude # sign in\n# Merged with the explicit [[anthropic.accounts]] entries below (explicit wins).\n# accounts_dir = \"~/.config/ai-usagebar/accounts\"\n# Hide the default (unnamed) Claude tab when you manage every account explicitly\n# above — otherwise you get an extra tab for the ambient ~/.claude / Keychain\n# login. Default true; ignored when there are no named accounts.\n# show_default_account = false\n# Or list accounts explicitly:\n# [[anthropic.accounts]]\n# label = \"work\"\n# credentials_path = \"~/.config/ai-usagebar/accounts/work/.credentials.json\"\n\n# Anthropic (API) — unlike the balance vendors above (which show your REMAINING\n# credit), Anthropic does not expose the prepaid credit balance over any API\n# (Console dashboard only), so this reports your month-to-date SPEND (consumed)\n# instead — for your API/Console account, which is separate from the Claude Code\n# OAuth login above.\n#\n# NOTE: the cost API does NOT report Priority Tier usage, so if your\n# organization is on Priority Tier the figure shown here is LOWER than your\n# real total spend. See\n# https://platform.claude.com/docs/en/manage-claude/usage-cost-api\n#\n# Opt-in (disabled until you set a key). Requires a Console *Admin key*\n# (`sk-ant-admin01-...`, sent as the `x-api-key` header), distinct from an\n# inference key. Admin keys exist only for ORGANIZATION accounts: set one up\n# first (Console > Settings > Organization) — the .../settings/admin-keys page\n# 404s for individual accounts.\n[anthropic_api]\nenabled = false\napi_key_env = \"ANTHROPIC_ADMIN_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-ant-admin01-...\" # fallback; chmod 600 the file if you use it\n# Positive, finite monthly USD limit for the spend-vs-limit % display (the API\n# doesn't expose a limit). With it: \"$1.34 / $1000 · 0%\". Without it:\n# \"$1.34/mo\".\n# monthly_limit = 1000\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# RESERVED — setting this does nothing today. It names the env var a future\n# API-key-only path would read (admin key → `/v1/organization/costs`). No code\n# consumes it: OpenAI usage comes only from Codex OAuth. Left commented out so\n# it isn't mistaken for a working alternative to `codex login`.\n# admin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n[kimi]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Shows Kimi Code subscription quota (weekly + 5h rolling window) from the\n# undocumented, community-confirmed api.kimi.com/coding/v1/usages endpoint.\nenabled = false\napi_key_env = \"KIMI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n# --- Account-balance vendors -------------------------------------------------\n# These report your REMAINING credit as money, rather than plan usage as a %.\n# All are opt-in and never fetch until enabled with a key present.\n\n[kilo]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the undocumented api.kilo.ai/api/profile/balance\n# endpoint (used internally by the Kilo Code extension).\nenabled = false\napi_key_env = \"KILO_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Scopes the balance to a team via the x-kilocode-organizationid header.\n# Omit for the personal balance. Switching this refetches rather than reusing\n# the previous account's cached figure.\n# organization_id = \"org_...\"\n\n[novita]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the documented\n# api.novita.ai/openapi/v1/billing/balance/detail endpoint.\nenabled = false\napi_key_env = \"NOVITA_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n\n[moonshot]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Account balance from the documented /v1/users/me/balance endpoint.\nenabled = false\napi_key_env = \"MOONSHOT_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n# Region picks BOTH the host and the currency, so switching it refetches\n# rather than showing the other region's figure:\n# global -> api.moonshot.ai (USD) | cn -> api.moonshot.cn (CNY)\n# region = \"global\"\n\n[grok]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Prepaid credit from the documented xAI Management API\n# management-api.x.ai/v1/billing/teams/{team}/prepaid/balance.\n#\n# This is the *Management* key (xAI Console > Management keys), NOT the\n# inference key you use for chat completions.\nenabled = false\napi_key_env = \"XAI_MANAGEMENT_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# The balance is per-team. A TEAM-scoped management key identifies its team on\n# its own; an ORGANIZATION-scoped key cannot (its scopeId is an organization,\n# not a team), so set the team explicitly here. Without it, an org-scoped key\n# reports an error saying so instead of querying the wrong team.\n# team_id = \"...\"\n\n# Google Antigravity. No credentials: usage is read from whichever local\n# Antigravity product is running (Antigravity 2.0, the `agy` CLI, or the IDE) —\n# all three share one account-wide quota. Set ANTIGRAVITY_LS_ADDRESS to\n# \"host:port\" only if auto-discovery of that local server ever fails.\n[antigravity]\nenabled = false\n"}, "f515769c98ab0a938db48898081427712f5ed104": {"t": 1785750130.8447788, "n": 8358, "h": "a951e6044a036cc7", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek | kimi\n# primary = \"anthropic\"\n\n# Optional, TUI-only monitor for local Claude Code session context. When\n# enabled, press `c` in ai-usagebar-tui. The scanner reads only bounded tails\n# below ~/.claude/projects, skips subagent transcripts and never follows\n# discovered symlinks. Nothing is read while this remains disabled.\n[context]\nenabled = false\n# Where the view docks when opened: full (own screen) | split (beside the\n# dashboard) | bottom. `v` cycles it while open; this is the starting value.\nlayout = \"full\"\n# projects_path = \"~/.claude/projects\" # default\n# Set a fallback only when it matches the sessions you use. Without a known\n# denominator the overlay shows raw input tokens rather than inventing a %.\n# context_window_tokens = 200000\n# Exact per-model values take precedence, which is useful when 200K and 1M\n# sessions coexist. Keys must match the model id stored in the transcript.\n# [context.model_context_window_tokens]\n# \"claude-opus-4-6\" = 1000000\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n\n# Anthropic (API) — unlike the balance vendors above (which show your REMAINING\n# credit), Anthropic does not expose the prepaid credit balance over any API\n# (Console dashboard only), so this reports your month-to-date SPEND (consumed)\n# instead — for your API/Console account, which is separate from the Claude Code\n# OAuth login above.\n#\n# NOTE: the cost API does NOT report Priority Tier usage, so if your\n# organization is on Priority Tier the figure shown here is LOWER than your\n# real total spend. See\n# https://platform.claude.com/docs/en/manage-claude/usage-cost-api\n#\n# Opt-in (disabled until you set a key). Requires a Console *Admin key*\n# (`sk-ant-admin01-...`, sent as the `x-api-key` header), distinct from an\n# inference key. Admin keys exist only for ORGANIZATION accounts: set one up\n# first (Console > Settings > Organization) — the .../settings/admin-keys page\n# 404s for individual accounts.\n[anthropic_api]\nenabled = false\napi_key_env = \"ANTHROPIC_ADMIN_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-ant-admin01-...\" # fallback; chmod 600 the file if you use it\n# Positive, finite monthly USD limit for the spend-vs-limit % display (the API\n# doesn't expose a limit). With it: \"$1.34 / $1000 · 0%\". Without it:\n# \"$1.34/mo\".\n# monthly_limit = 1000\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# RESERVED — setting this does nothing today. It names the env var a future\n# API-key-only path would read (admin key → `/v1/organization/costs`). No code\n# consumes it: OpenAI usage comes only from Codex OAuth. Left commented out so\n# it isn't mistaken for a working alternative to `codex login`.\n# admin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n[kimi]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Shows Kimi Code subscription quota (weekly + 5h rolling window) from the\n# undocumented, community-confirmed api.kimi.com/coding/v1/usages endpoint.\nenabled = false\napi_key_env = \"KIMI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n# --- Account-balance vendors -------------------------------------------------\n# These report your REMAINING credit as money, rather than plan usage as a %.\n# All are opt-in and never fetch until enabled with a key present.\n\n[kilo]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the undocumented api.kilo.ai/api/profile/balance\n# endpoint (used internally by the Kilo Code extension).\nenabled = false\napi_key_env = \"KILO_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Scopes the balance to a team via the x-kilocode-organizationid header.\n# Omit for the personal balance. Switching this refetches rather than reusing\n# the previous account's cached figure.\n# organization_id = \"org_...\"\n\n[novita]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the documented\n# api.novita.ai/openapi/v1/billing/balance/detail endpoint.\nenabled = false\napi_key_env = \"NOVITA_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n\n[moonshot]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Account balance from the documented /v1/users/me/balance endpoint.\nenabled = false\napi_key_env = \"MOONSHOT_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n# Region picks BOTH the host and the currency, so switching it refetches\n# rather than showing the other region's figure:\n# global -> api.moonshot.ai (USD) | cn -> api.moonshot.cn (CNY)\n# region = \"global\"\n\n[grok]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Prepaid credit from the documented xAI Management API\n# management-api.x.ai/v1/billing/teams/{team}/prepaid/balance.\n#\n# This is the *Management* key (xAI Console > Management keys), NOT the\n# inference key you use for chat completions.\nenabled = false\napi_key_env = \"XAI_MANAGEMENT_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# The balance is per-team. A TEAM-scoped management key identifies its team on\n# its own; an ORGANIZATION-scoped key cannot (its scopeId is an organization,\n# not a team), so set the team explicitly here. Without it, an org-scoped key\n# reports an error saying so instead of querying the wrong team.\n# team_id = \"...\"\n\n# Google Antigravity. No credentials: usage is read from whichever local\n# Antigravity product is running (Antigravity 2.0, the `agy` CLI, or the IDE) —\n# all three share one account-wide quota. Set ANTIGRAVITY_LS_ADDRESS to\n# \"host:port\" only if auto-discovery of that local server ever fails.\n[antigravity]\nenabled = false\n"}, "090ed42208c9beaf3e79b9ec74ee115fc2b2dc78": {"t": 1785750131.9169693, "n": 8032, "h": "9d7562b3d2c3d128", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek | kimi\n# primary = \"anthropic\"\n\n# Optional, TUI-only monitor for local Claude Code session context. When\n# enabled, press `c` in ai-usagebar-tui. The scanner reads only bounded tails\n# below ~/.claude/projects, skips subagent transcripts and never follows\n# discovered symlinks. Nothing is read while this remains disabled.\n[context]\nenabled = false\n# Where the view docks when opened: full (own screen) | split (beside the\n# dashboard) | bottom. `v` cycles it while open; this is the starting value.\nlayout = \"full\"\n# projects_path = \"~/.claude/projects\" # default\n# Set a fallback only when it matches the sessions you use. Without a known\n# denominator the overlay shows raw input tokens rather than inventing a %.\n# context_window_tokens = 200000\n# Exact per-model values take precedence, which is useful when 200K and 1M\n# sessions coexist. Keys must match the model id stored in the transcript.\n# [context.model_context_window_tokens]\n# \"claude-opus-4-6\" = 1000000\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n\n# Anthropic (API) — unlike the balance vendors above (which show your REMAINING\n# credit), Anthropic does not expose the prepaid credit balance over any API\n# (Console dashboard only), so this reports your month-to-date SPEND (consumed)\n# instead — for your API/Console account, which is separate from the Claude Code\n# OAuth login above.\n#\n# NOTE: the cost API does NOT report Priority Tier usage, so if your\n# organization is on Priority Tier the figure shown here is LOWER than your\n# real total spend. See\n# https://platform.claude.com/docs/en/manage-claude/usage-cost-api\n#\n# Opt-in (disabled until you set a key). Requires a Console *Admin key*\n# (`sk-ant-admin01-...`, sent as the `x-api-key` header), distinct from an\n# inference key. Admin keys exist only for ORGANIZATION accounts: set one up\n# first (Console > Settings > Organization) — the .../settings/admin-keys page\n# 404s for individual accounts.\n[anthropic_api]\nenabled = false\napi_key_env = \"ANTHROPIC_ADMIN_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-ant-admin01-...\" # fallback; chmod 600 the file if you use it\n# Positive, finite monthly USD limit for the spend-vs-limit % display (the API\n# doesn't expose a limit). With it: \"$1.34 / $1000 · 0%\". Without it:\n# \"$1.34/mo\".\n# monthly_limit = 1000\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# RESERVED — setting this does nothing today. It names the env var a future\n# API-key-only path would read (admin key → `/v1/organization/costs`). No code\n# consumes it: OpenAI usage comes only from Codex OAuth. Left commented out so\n# it isn't mistaken for a working alternative to `codex login`.\n# admin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n[kimi]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Shows Kimi Code subscription quota (weekly + 5h rolling window) from the\n# undocumented, community-confirmed api.kimi.com/coding/v1/usages endpoint.\nenabled = false\napi_key_env = \"KIMI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n# --- Account-balance vendors -------------------------------------------------\n# These report your REMAINING credit as money, rather than plan usage as a %.\n# All are opt-in and never fetch until enabled with a key present.\n\n[kilo]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the undocumented api.kilo.ai/api/profile/balance\n# endpoint (used internally by the Kilo Code extension).\nenabled = false\napi_key_env = \"KILO_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Scopes the balance to a team via the x-kilocode-organizationid header.\n# Omit for the personal balance. Switching this refetches rather than reusing\n# the previous account's cached figure.\n# organization_id = \"org_...\"\n\n[novita]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the documented\n# api.novita.ai/openapi/v1/billing/balance/detail endpoint.\nenabled = false\napi_key_env = \"NOVITA_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n\n[moonshot]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Account balance from the documented /v1/users/me/balance endpoint.\nenabled = false\napi_key_env = \"MOONSHOT_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n# Region picks BOTH the host and the currency, so switching it refetches\n# rather than showing the other region's figure:\n# global -> api.moonshot.ai (USD) | cn -> api.moonshot.cn (CNY)\n# region = \"global\"\n\n[grok]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Prepaid credit from the documented xAI Management API\n# management-api.x.ai/v1/billing/teams/{team}/prepaid/balance.\n#\n# This is the *Management* key (xAI Console > Management keys), NOT the\n# inference key you use for chat completions.\nenabled = false\napi_key_env = \"XAI_MANAGEMENT_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# The balance is per-team. A TEAM-scoped management key identifies its team on\n# its own; an ORGANIZATION-scoped key cannot (its scopeId is an organization,\n# not a team), so set the team explicitly here. Without it, an org-scoped key\n# reports an error saying so instead of querying the wrong team.\n# team_id = \"...\"\n"}, "acc564f042a65523e524c8f1f80778f70375cfd5": {"t": 1785750133.6180124, "n": 7865, "h": "6a32c36a580d228a", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek | kimi\n# primary = \"anthropic\"\n\n# Optional, TUI-only monitor for local Claude Code session context. When\n# enabled, press `c` in ai-usagebar-tui. The scanner reads only bounded tails\n# below ~/.claude/projects, skips subagent transcripts and never follows\n# discovered symlinks. Nothing is read while this remains disabled.\n[context]\nenabled = false\n# projects_path = \"~/.claude/projects\" # default\n# Set a fallback only when it matches the sessions you use. Without a known\n# denominator the overlay shows raw input tokens rather than inventing a %.\n# context_window_tokens = 200000\n# Exact per-model values take precedence, which is useful when 200K and 1M\n# sessions coexist. Keys must match the model id stored in the transcript.\n# [context.model_context_window_tokens]\n# \"claude-opus-4-6\" = 1000000\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n\n# Anthropic (API) — unlike the balance vendors above (which show your REMAINING\n# credit), Anthropic does not expose the prepaid credit balance over any API\n# (Console dashboard only), so this reports your month-to-date SPEND (consumed)\n# instead — for your API/Console account, which is separate from the Claude Code\n# OAuth login above.\n#\n# NOTE: the cost API does NOT report Priority Tier usage, so if your\n# organization is on Priority Tier the figure shown here is LOWER than your\n# real total spend. See\n# https://platform.claude.com/docs/en/manage-claude/usage-cost-api\n#\n# Opt-in (disabled until you set a key). Requires a Console *Admin key*\n# (`sk-ant-admin01-...`, sent as the `x-api-key` header), distinct from an\n# inference key. Admin keys exist only for ORGANIZATION accounts: set one up\n# first (Console > Settings > Organization) — the .../settings/admin-keys page\n# 404s for individual accounts.\n[anthropic_api]\nenabled = false\napi_key_env = \"ANTHROPIC_ADMIN_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-ant-admin01-...\" # fallback; chmod 600 the file if you use it\n# Positive, finite monthly USD limit for the spend-vs-limit % display (the API\n# doesn't expose a limit). With it: \"$1.34 / $1000 · 0%\". Without it:\n# \"$1.34/mo\".\n# monthly_limit = 1000\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# RESERVED — setting this does nothing today. It names the env var a future\n# API-key-only path would read (admin key → `/v1/organization/costs`). No code\n# consumes it: OpenAI usage comes only from Codex OAuth. Left commented out so\n# it isn't mistaken for a working alternative to `codex login`.\n# admin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n[kimi]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Shows Kimi Code subscription quota (weekly + 5h rolling window) from the\n# undocumented, community-confirmed api.kimi.com/coding/v1/usages endpoint.\nenabled = false\napi_key_env = \"KIMI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n# --- Account-balance vendors -------------------------------------------------\n# These report your REMAINING credit as money, rather than plan usage as a %.\n# All are opt-in and never fetch until enabled with a key present.\n\n[kilo]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the undocumented api.kilo.ai/api/profile/balance\n# endpoint (used internally by the Kilo Code extension).\nenabled = false\napi_key_env = \"KILO_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Scopes the balance to a team via the x-kilocode-organizationid header.\n# Omit for the personal balance. Switching this refetches rather than reusing\n# the previous account's cached figure.\n# organization_id = \"org_...\"\n\n[novita]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the documented\n# api.novita.ai/openapi/v1/billing/balance/detail endpoint.\nenabled = false\napi_key_env = \"NOVITA_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n\n[moonshot]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Account balance from the documented /v1/users/me/balance endpoint.\nenabled = false\napi_key_env = \"MOONSHOT_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n# Region picks BOTH the host and the currency, so switching it refetches\n# rather than showing the other region's figure:\n# global -> api.moonshot.ai (USD) | cn -> api.moonshot.cn (CNY)\n# region = \"global\"\n\n[grok]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Prepaid credit from the documented xAI Management API\n# management-api.x.ai/v1/billing/teams/{team}/prepaid/balance.\n#\n# This is the *Management* key (xAI Console > Management keys), NOT the\n# inference key you use for chat completions.\nenabled = false\napi_key_env = \"XAI_MANAGEMENT_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# The balance is per-team. A TEAM-scoped management key identifies its team on\n# its own; an ORGANIZATION-scoped key cannot (its scopeId is an organization,\n# not a team), so set the team explicitly here. Without it, an org-scoped key\n# reports an error saying so instead of querying the wrong team.\n# team_id = \"...\"\n"}, "66c1d77446457a65ab4b8dbe72d55a1516f292c0": {"t": 1785750135.7569444, "n": 7091, "h": "266c579c49c95c73", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek | kimi\n# primary = \"anthropic\"\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n\n# Anthropic (API) — unlike the balance vendors above (which show your REMAINING\n# credit), Anthropic does not expose the prepaid credit balance over any API\n# (Console dashboard only), so this reports your month-to-date SPEND (consumed)\n# instead — for your API/Console account, which is separate from the Claude Code\n# OAuth login above.\n#\n# NOTE: the cost API does NOT report Priority Tier usage, so if your\n# organization is on Priority Tier the figure shown here is LOWER than your\n# real total spend. See\n# https://platform.claude.com/docs/en/manage-claude/usage-cost-api\n#\n# Opt-in (disabled until you set a key). Requires a Console *Admin key*\n# (`sk-ant-admin01-...`, sent as the `x-api-key` header), distinct from an\n# inference key. Admin keys exist only for ORGANIZATION accounts: set one up\n# first (Console > Settings > Organization) — the .../settings/admin-keys page\n# 404s for individual accounts.\n[anthropic_api]\nenabled = false\napi_key_env = \"ANTHROPIC_ADMIN_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-ant-admin01-...\" # fallback; chmod 600 the file if you use it\n# Positive, finite monthly USD limit for the spend-vs-limit % display (the API\n# doesn't expose a limit). With it: \"$1.34 / $1000 · 0%\". Without it:\n# \"$1.34/mo\".\n# monthly_limit = 1000\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# RESERVED — setting this does nothing today. It names the env var a future\n# API-key-only path would read (admin key → `/v1/organization/costs`). No code\n# consumes it: OpenAI usage comes only from Codex OAuth. Left commented out so\n# it isn't mistaken for a working alternative to `codex login`.\n# admin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n[kimi]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Shows Kimi Code subscription quota (weekly + 5h rolling window) from the\n# undocumented, community-confirmed api.kimi.com/coding/v1/usages endpoint.\nenabled = false\napi_key_env = \"KIMI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n# --- Account-balance vendors -------------------------------------------------\n# These report your REMAINING credit as money, rather than plan usage as a %.\n# All are opt-in and never fetch until enabled with a key present.\n\n[kilo]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the undocumented api.kilo.ai/api/profile/balance\n# endpoint (used internally by the Kilo Code extension).\nenabled = false\napi_key_env = \"KILO_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Scopes the balance to a team via the x-kilocode-organizationid header.\n# Omit for the personal balance. Switching this refetches rather than reusing\n# the previous account's cached figure.\n# organization_id = \"org_...\"\n\n[novita]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the documented\n# api.novita.ai/openapi/v1/billing/balance/detail endpoint.\nenabled = false\napi_key_env = \"NOVITA_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n\n[moonshot]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Account balance from the documented /v1/users/me/balance endpoint.\nenabled = false\napi_key_env = \"MOONSHOT_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n# Region picks BOTH the host and the currency, so switching it refetches\n# rather than showing the other region's figure:\n# global -> api.moonshot.ai (USD) | cn -> api.moonshot.cn (CNY)\n# region = \"global\"\n\n[grok]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Prepaid credit from the documented xAI Management API\n# management-api.x.ai/v1/billing/teams/{team}/prepaid/balance.\n#\n# This is the *Management* key (xAI Console > Management keys), NOT the\n# inference key you use for chat completions.\nenabled = false\napi_key_env = \"XAI_MANAGEMENT_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# The balance is per-team. A TEAM-scoped management key identifies its team on\n# its own; an ORGANIZATION-scoped key cannot (its scopeId is an organization,\n# not a team), so set the team explicitly here. Without it, an org-scoped key\n# reports an error saying so instead of querying the wrong team.\n# team_id = \"...\"\n"}, "acc93247860aa77ea66808caf443a085ae35ec8a": {"t": 1785750136.9428468, "n": 7072, "h": "820b7e403cc14e33", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek | kimi\n# primary = \"anthropic\"\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n\n# Anthropic (API) — unlike the balance vendors above (which show your REMAINING\n# credit), Anthropic does not expose the prepaid credit balance over any API\n# (Console dashboard only), so this reports your month-to-date SPEND (consumed)\n# instead — for your API/Console account, which is separate from the Claude Code\n# OAuth login above.\n#\n# NOTE: the cost API does NOT report Priority Tier usage, so if your\n# organization is on Priority Tier the figure shown here is LOWER than your\n# real total spend. See\n# https://platform.claude.com/docs/en/manage-claude/usage-cost-api\n#\n# Opt-in (disabled until you set a key). Requires a Console *Admin key*\n# (`sk-ant-admin01-...`, sent as the `x-api-key` header), distinct from an\n# inference key. Admin keys exist only for ORGANIZATION accounts: set one up\n# first (Console > Settings > Organization) — the .../settings/admin-keys page\n# 404s for individual accounts.\n[anthropic_api]\nenabled = false\napi_key_env = \"ANTHROPIC_ADMIN_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-ant-admin01-...\" # fallback; chmod 600 the file if you use it\n# Monthly USD limit for the spend-vs-limit % display (the API doesn't expose a\n# limit). With it: \"$1.34 / $1000 · 0%\". Without it: \"$1.34/mo\".\n# monthly_limit = 1000\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# RESERVED — setting this does nothing today. It names the env var a future\n# API-key-only path would read (admin key → `/v1/organization/costs`). No code\n# consumes it: OpenAI usage comes only from Codex OAuth. Left commented out so\n# it isn't mistaken for a working alternative to `codex login`.\n# admin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n[kimi]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Shows Kimi Code subscription quota (weekly + 5h rolling window) from the\n# undocumented, community-confirmed api.kimi.com/coding/v1/usages endpoint.\nenabled = false\napi_key_env = \"KIMI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n# --- Account-balance vendors -------------------------------------------------\n# These report your REMAINING credit as money, rather than plan usage as a %.\n# All are opt-in and never fetch until enabled with a key present.\n\n[kilo]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the undocumented api.kilo.ai/api/profile/balance\n# endpoint (used internally by the Kilo Code extension).\nenabled = false\napi_key_env = \"KILO_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Scopes the balance to a team via the x-kilocode-organizationid header.\n# Omit for the personal balance. Switching this refetches rather than reusing\n# the previous account's cached figure.\n# organization_id = \"org_...\"\n\n[novita]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the documented\n# api.novita.ai/openapi/v1/billing/balance/detail endpoint.\nenabled = false\napi_key_env = \"NOVITA_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n\n[moonshot]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Account balance from the documented /v1/users/me/balance endpoint.\nenabled = false\napi_key_env = \"MOONSHOT_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n# Region picks BOTH the host and the currency, so switching it refetches\n# rather than showing the other region's figure:\n# global -> api.moonshot.ai (USD) | cn -> api.moonshot.cn (CNY)\n# region = \"global\"\n\n[grok]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Prepaid credit from the documented xAI Management API\n# management-api.x.ai/v1/billing/teams/{team}/prepaid/balance.\n#\n# This is the *Management* key (xAI Console > Management keys), NOT the\n# inference key you use for chat completions.\nenabled = false\napi_key_env = \"XAI_MANAGEMENT_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# The balance is per-team. A TEAM-scoped management key identifies its team on\n# its own; an ORGANIZATION-scoped key cannot (its scopeId is an organization,\n# not a team), so set the team explicitly here. Without it, an org-scoped key\n# reports an error saying so instead of querying the wrong team.\n# team_id = \"...\"\n"}, "0f5533294ddd2c3ad172a24a27e0977403bd442b": {"t": 1785750138.1585448, "n": 6834, "h": "4aa0d55e89fa94d8", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek | kimi\n# primary = \"anthropic\"\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n\n# Anthropic (API) — unlike the balance vendors above (which show your REMAINING\n# credit), Anthropic does not expose the prepaid credit balance over any API\n# (Console dashboard only), so this reports your month-to-date SPEND (consumed)\n# instead — for your API/Console account, which is separate from the Claude Code\n# OAuth login above.\n#\n# Opt-in (disabled until you set a key). Requires a Console *Admin key*\n# (`sk-ant-admin01-...`, sent as the `x-api-key` header), distinct from an\n# inference key. Admin keys exist only for ORGANIZATION accounts: set one up\n# first (Console > Settings > Organization) — the .../settings/admin-keys page\n# 404s for individual accounts.\n[anthropic_api]\nenabled = false\napi_key_env = \"ANTHROPIC_ADMIN_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-ant-admin01-...\" # fallback; chmod 600 the file if you use it\n# Monthly USD limit for the spend-vs-limit % display (the API doesn't expose a\n# limit). With it: \"$1.34 / $1000 · 0%\". Without it: \"$1.34/mo\".\n# monthly_limit = 1000\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# RESERVED — setting this does nothing today. It names the env var a future\n# API-key-only path would read (admin key → `/v1/organization/costs`). No code\n# consumes it: OpenAI usage comes only from Codex OAuth. Left commented out so\n# it isn't mistaken for a working alternative to `codex login`.\n# admin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n[kimi]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Shows Kimi Code subscription quota (weekly + 5h rolling window) from the\n# undocumented, community-confirmed api.kimi.com/coding/v1/usages endpoint.\nenabled = false\napi_key_env = \"KIMI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n# --- Account-balance vendors -------------------------------------------------\n# These report your REMAINING credit as money, rather than plan usage as a %.\n# All are opt-in and never fetch until enabled with a key present.\n\n[kilo]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the undocumented api.kilo.ai/api/profile/balance\n# endpoint (used internally by the Kilo Code extension).\nenabled = false\napi_key_env = \"KILO_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Scopes the balance to a team via the x-kilocode-organizationid header.\n# Omit for the personal balance. Switching this refetches rather than reusing\n# the previous account's cached figure.\n# organization_id = \"org_...\"\n\n[novita]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the documented\n# api.novita.ai/openapi/v1/billing/balance/detail endpoint.\nenabled = false\napi_key_env = \"NOVITA_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n\n[moonshot]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Account balance from the documented /v1/users/me/balance endpoint.\nenabled = false\napi_key_env = \"MOONSHOT_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n# Region picks BOTH the host and the currency, so switching it refetches\n# rather than showing the other region's figure:\n# global -> api.moonshot.ai (USD) | cn -> api.moonshot.cn (CNY)\n# region = \"global\"\n\n[grok]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Prepaid credit from the documented xAI Management API\n# management-api.x.ai/v1/billing/teams/{team}/prepaid/balance.\n#\n# This is the *Management* key (xAI Console > Management keys), NOT the\n# inference key you use for chat completions.\nenabled = false\napi_key_env = \"XAI_MANAGEMENT_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# The balance is per-team. A TEAM-scoped management key identifies its team on\n# its own; an ORGANIZATION-scoped key cannot (its scopeId is an organization,\n# not a team), so set the team explicitly here. Without it, an org-scoped key\n# reports an error saying so instead of querying the wrong team.\n# team_id = \"...\"\n"}, "84f244da5ae67d5866e7ddc8fa931aa737b7057f": {"t": 1785750139.2495189, "n": 5796, "h": "7307068570df873a", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek | kimi\n# primary = \"anthropic\"\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# RESERVED — setting this does nothing today. It names the env var a future\n# API-key-only path would read (admin key → `/v1/organization/costs`). No code\n# consumes it: OpenAI usage comes only from Codex OAuth. Left commented out so\n# it isn't mistaken for a working alternative to `codex login`.\n# admin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n[kimi]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Shows Kimi Code subscription quota (weekly + 5h rolling window) from the\n# undocumented, community-confirmed api.kimi.com/coding/v1/usages endpoint.\nenabled = false\napi_key_env = \"KIMI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n# --- Account-balance vendors -------------------------------------------------\n# These report your REMAINING credit as money, rather than plan usage as a %.\n# All are opt-in and never fetch until enabled with a key present.\n\n[kilo]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the undocumented api.kilo.ai/api/profile/balance\n# endpoint (used internally by the Kilo Code extension).\nenabled = false\napi_key_env = \"KILO_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# Scopes the balance to a team via the x-kilocode-organizationid header.\n# Omit for the personal balance. Switching this refetches rather than reusing\n# the previous account's cached figure.\n# organization_id = \"org_...\"\n\n[novita]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Remaining credit from the documented\n# api.novita.ai/openapi/v1/billing/balance/detail endpoint.\nenabled = false\napi_key_env = \"NOVITA_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n\n[moonshot]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Account balance from the documented /v1/users/me/balance endpoint.\nenabled = false\napi_key_env = \"MOONSHOT_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n# Region picks BOTH the host and the currency, so switching it refetches\n# rather than showing the other region's figure:\n# global -> api.moonshot.ai (USD) | cn -> api.moonshot.cn (CNY)\n# region = \"global\"\n\n[grok]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Saving a key in the Settings overlay writes the inline fallback AND flips\n# `enabled` to true; clearing the field removes the inline key again.\n# Prepaid credit from the documented xAI Management API\n# management-api.x.ai/v1/billing/teams/{team}/prepaid/balance.\n#\n# This is the *Management* key (xAI Console > Management keys), NOT the\n# inference key you use for chat completions.\nenabled = false\napi_key_env = \"XAI_MANAGEMENT_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback; chmod 600 the file if you use it\n# The balance is per-team. A TEAM-scoped management key identifies its team on\n# its own; an ORGANIZATION-scoped key cannot (its scopeId is an organization,\n# not a team), so set the team explicitly here. Without it, an org-scoped key\n# reports an error saying so instead of querying the wrong team.\n# team_id = \"...\"\n"}, "739162a19661d8eff7ca8c9dbd573f6c69851778": {"t": 1785750140.3264542, "n": 2550, "h": "a0672d6dd729eae1", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek | kimi\n# primary = \"anthropic\"\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# RESERVED — setting this does nothing today. It names the env var a future\n# API-key-only path would read (admin key → `/v1/organization/costs`). No code\n# consumes it: OpenAI usage comes only from Codex OAuth. Left commented out so\n# it isn't mistaken for a working alternative to `codex login`.\n# admin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\n# The Settings overlay can save the inline fallback; it does not enable this vendor.\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n[kimi]\n# Disabled by default — enable once you've set a key (env var or inline).\n# The Settings overlay can save the inline fallback; it does not enable this vendor.\n# Shows Kimi Code subscription quota (weekly + 5h rolling window) from the\n# undocumented, community-confirmed api.kimi.com/coding/v1/usages endpoint.\nenabled = false\napi_key_env = \"KIMI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n"}, "69fa260db141f3971a2319798ca1c41a74f4e434": {"t": 1785750141.5081952, "n": 2383, "h": "aee69dda00f2e899", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek | kimi\n# primary = \"anthropic\"\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# Reserved for a future API-key-only fallback path (admin key reads\n# `/v1/organization/costs`). Today, OpenAI requires Codex OAuth.\nadmin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\n# The Settings overlay can save the inline fallback; it does not enable this vendor.\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n[kimi]\n# Disabled by default — enable once you've set a key (env var or inline).\n# The Settings overlay can save the inline fallback; it does not enable this vendor.\n# Shows Kimi Code subscription quota (weekly + 5h rolling window) from the\n# undocumented, community-confirmed api.kimi.com/coding/v1/usages endpoint.\nenabled = false\napi_key_env = \"KIMI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n"}, "56f1e5b23308c626dff46177490e9c53e6bfc54f": {"t": 1785750142.9106572, "n": 2147, "h": "d5e9f453edf83d65", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek | kimi\n# primary = \"anthropic\"\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# Reserved for a future API-key-only fallback path (admin key reads\n# `/v1/organization/costs`). Today, OpenAI requires Codex OAuth.\nadmin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n\n[kimi]\n# Disabled by default — enable once you've set a key (env var or inline).\n# Shows Kimi Code subscription quota (weekly + 5h rolling window) from api.kimi.com.\nenabled = false\napi_key_env = \"KIMI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n"}, "d56d0273cd3eba884d92678bc2bb831ee7980c13": {"t": 1785750143.998578, "n": 1802, "h": "d5ba3c8b7cf2384e", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter | deepseek\n# primary = \"anthropic\"\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# Reserved for a future API-key-only fallback path (admin key reads\n# `/v1/organization/costs`). Today, OpenAI requires Codex OAuth.\nadmin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n\n[deepseek]\n# Disabled by default — enable once you've set a key (env var or inline).\nenabled = false\napi_key_env = \"DEEPSEEK_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"sk-...\" # fallback; chmod 600 the file if you use it\n"}, "8eb309eb1a41e585c2ccb668eb9f22b849056097": {"t": 1785750145.1957786, "n": 1534, "h": "d48338205d5deb4f", "c": "# ai-usagebar configuration. Lives at ~/.config/ai-usagebar/config.toml.\n# Every section and field is optional — missing config = defaults below.\n#\n# IMPORTANT: if you put an inline `api_key` in any section, chmod 600 the\n# file (`chmod 600 ~/.config/ai-usagebar/config.toml`). Otherwise env vars\n# are the safer choice.\n\n[ui]\n# Which vendor the widget shows when `--vendor` is omitted, AND which tab\n# is selected when the TUI opens. Comment out to default to Anthropic.\n# Valid: anthropic | openai | zai | openrouter\n# primary = \"anthropic\"\n\n[anthropic]\nenabled = true\n# Path to the Claude CLI OAuth credentials. Leave commented out to use\n# the default ~/.claude/.credentials.json.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n\n[openai]\nenabled = true\n# Path to the Codex CLI OAuth credentials. Leave commented out to use\n# the default ~/.codex/auth.json.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# Reserved for a future API-key-only fallback path (admin key reads\n# `/v1/organization/costs`). Today, OpenAI requires Codex OAuth.\nadmin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\n# Credential resolution order: env var → inline api_key → error.\napi_key_env = \"ZAI_API_KEY\" # checked first; if set + non-empty, used\n# api_key = \"...\" # fallback for users who don't export env vars\n# Display-only. Used when the API doesn't return a plan tier itself.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n# api_key = \"sk-or-v1-...\"\n"}, "c04f73e91ad5285e31c4cf7c3fb1d63c773ceae5": {"t": 1785750146.2659497, "n": 889, "h": "7d53b2f3754bad97", "c": "# ai-usagebar configuration. Defaults shown; copy to\n# ~/.config/ai-usagebar/config.toml and edit as needed. Every section and\n# every field is optional — missing config is treated as defaults.\n\n[anthropic]\nenabled = true\n# Override path to the Claude CLI's OAuth credentials file.\n# credentials_path = \"/home/you/.claude/.credentials.json\"\n\n[openai]\nenabled = true\n# Primary path: Codex OAuth from ~/.codex/auth.json (run `codex login` once).\n# Gives 5h/weekly windows like Claude. No env var needed for OAuth.\n# codex_auth_path = \"/home/you/.codex/auth.json\"\n# Optional fallback for API-key-only users — admin key for MTD spend.\nadmin_key_env = \"OPENAI_ADMIN_KEY\"\n\n[zai]\nenabled = true\napi_key_env = \"ZAI_API_KEY\"\n# Display-only — used when the API doesn't return a `level` field.\n# plan_tier = \"lite\" # lite | pro | max\n\n[openrouter]\nenabled = true\napi_key_env = \"OPENROUTER_API_KEY\"\n"}}, "macos/ai-usagebar-menubar.swift": {"997730bf6f794e7d3abac3fee5927796e434f67c": {"t": 1785745881.9202783, "n": 119710, "h": "eaea24fdf9e43e8f", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\n/// Squeeze a countdown (\"4d 1h\", \"2h 05m\", \"now\") down to its leading unit for\n/// the overview status-bar title, where every character fights for space:\n/// \"4d 1h\" → \"4d\", \"2h 05m\" → \"2h\", \"0h 05m\" → \"5m\". Missing/em-dash → nil.\nfunc shortReset(_ r: String) -> String? {\n guard !r.isEmpty, r != \"—\" else { return nil }\n let parts = r.split(separator: \" \")\n guard let first = parts.first else { return nil }\n if first == \"0h\", parts.count > 1 {\n let m = parts[1].drop(while: { $0 == \"0\" })\n return m == \"m\" ? \"0m\" : String(m)\n }\n return String(first)\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\n/// Read a TOML array of quoted strings. This intentionally stays small (the\n/// native app is a single dependency-free Swift file), but accepts multiline\n/// arrays and rejects malformed values instead of silently widening Overview.\nfunc tomlStringArrayInText(_ text: String, section: String, key: String) -> [String]? {\n func withoutComment(_ line: String) -> String {\n var result = \"\"\n var quote: Character?\n var escaped = false\n for ch in line {\n if let active = quote {\n result.append(ch)\n if active == \"\\\"\", escaped {\n escaped = false\n } else if active == \"\\\"\", ch == \"\\\\\" {\n escaped = true\n } else if ch == active {\n quote = nil\n }\n } else if ch == \"#\" {\n break\n } else {\n result.append(ch)\n if ch == \"\\\"\" || ch == \"'\" { quote = ch }\n }\n }\n return result\n }\n\n func hasClosingBracket(_ value: String) -> Bool {\n var quote: Character?\n var escaped = false\n for ch in value {\n if let active = quote {\n if active == \"\\\"\", escaped {\n escaped = false\n } else if active == \"\\\"\", ch == \"\\\\\" {\n escaped = true\n } else if ch == active {\n quote = nil\n }\n } else if ch == \"\\\"\" || ch == \"'\" {\n quote = ch\n } else if ch == \"]\" {\n return true\n }\n }\n return false\n }\n\n var inSection = false\n var rawValue: String?\n for rawLine in text.components(separatedBy: .newlines) {\n let line = withoutComment(rawLine).trimmingCharacters(in: .whitespaces)\n if let current = rawValue {\n let joined = current + \" \" + line\n rawValue = joined\n if hasClosingBracket(joined) { break }\n continue\n }\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.isEmpty else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n guard value.hasPrefix(\"[\") else { return nil }\n rawValue = value\n if hasClosingBracket(value) { break }\n }\n\n guard let raw = rawValue, hasClosingBracket(raw) else { return nil }\n let chars = Array(raw)\n guard chars.first == \"[\" else { return nil }\n var result: [String] = []\n var i = 1\n\n func skipSpace() {\n while i < chars.count && chars[i].isWhitespace { i += 1 }\n }\n\n while true {\n skipSpace()\n guard i < chars.count else { return nil }\n if chars[i] == \"]\" {\n i += 1\n skipSpace()\n return i == chars.count ? result : nil\n }\n let quote = chars[i]\n guard quote == \"\\\"\" || quote == \"'\" else { return nil }\n i += 1\n var value = \"\"\n var closed = false\n while i < chars.count {\n let ch = chars[i]\n i += 1\n if ch == quote {\n closed = true\n break\n }\n if quote == \"\\\"\", ch == \"\\\\\", i < chars.count {\n value.append(chars[i])\n i += 1\n } else {\n value.append(ch)\n }\n }\n guard closed else { return nil }\n result.append(value)\n skipSpace()\n guard i < chars.count else { return nil }\n if chars[i] == \",\" {\n i += 1\n continue\n }\n guard chars[i] == \"]\" else { return nil }\n }\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc configuredOverviewVendorIds() -> [String]? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlStringArrayInText(text, section: \"ui\", key: \"overview_vendors\")\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n// ── Anthropic multi-account ────────────────────────────────────────────────\n// Mirrors the Rust side (`src/config.rs`): explicit `[[anthropic.accounts]]`\n// entries plus subdirectories of `[anthropic] accounts_dir`, explicit labels\n// winning on a clash. Each account is a\n// selectable entry with the pseudo-id `anthropic@<label>`, fetched by the\n// binary as `--vendor anthropic --account <label>` — the binary resolves the\n// account's credentials itself (file or its own Keychain item), so the app\n// never needs to know where they live.\n\n/// `[[anthropic.accounts]]` labels, in file order. Pure text scan, like the\n/// rest of this app's TOML reading: a `[[anthropic.accounts]]` header opens a\n/// block, the first `label = \"…\"` inside it counts.\nfunc anthropicAccountLabels(inTOML text: String) -> [String] {\n var labels: [String] = []\n var inBlock = false\n for rawLine in text.components(separatedBy: .newlines) {\n let line = rawLine.trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inBlock = line == \"[[anthropic.accounts]]\"\n continue\n }\n guard inBlock, line.hasPrefix(\"label\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == \"label\" else { continue }\n var value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n value = String(content[..<end])\n }\n if !value.isEmpty {\n labels.append(value)\n inBlock = false // one label per block\n }\n }\n return labels\n}\n\n/// Immediate subdirectories of `dir`, sorted — the same discovery rule as the\n/// Rust `accounts_dir` scan. Credentials may be file- or Keychain-backed.\nfunc discoverAccountLabels(inDir dir: String) -> [String] {\n let fm = FileManager.default\n guard let names = try? fm.contentsOfDirectory(atPath: dir) else { return [] }\n return names.filter { name in\n var isDir: ObjCBool = false\n let sub = \"\\(dir)/\\(name)\"\n return fm.fileExists(atPath: sub, isDirectory: &isDir) && isDir.boolValue\n }.sorted()\n}\n\n/// Explicit labels first (file order), then discovered ones that don't clash.\nfunc mergedAccountLabels(explicit: [String], discovered: [String]) -> [String] {\n explicit + discovered.filter { !explicit.contains($0) }\n}\n\n/// All configured Claude account labels, from the same config the binary reads.\nfunc claudeAccountLabels() -> [String] {\n guard let text = try? String(contentsOfFile: configPathTOML(), encoding: .utf8) else {\n return []\n }\n let explicit = anthropicAccountLabels(inTOML: text)\n var discovered: [String] = []\n if let dir = tomlValueInText(text, section: \"anthropic\", key: \"accounts_dir\"), !dir.isEmpty {\n discovered = discoverAccountLabels(inDir: NSString(string: dir).expandingTildeInPath)\n }\n return mergedAccountLabels(explicit: explicit, discovered: discovered)\n}\n\n/// Rust semantics (`show_default_account`): `false` hides the default\n/// (unnamed) Claude entry, but is ignored when there are no named accounts,\n/// so Anthropic never loses its only entry.\nfunc showDefaultClaudeAccount(configValue: String?, hasAccounts: Bool) -> Bool {\n guard hasAccounts else { return true }\n return configValue != \"false\"\n}\n\n/// Pseudo-id mapping: `anthropic@<label>` selects a named account.\nlet ACCOUNT_ID_PREFIX = \"anthropic@\"\n\nfunc accountLabel(of id: String) -> String? {\n id.hasPrefix(ACCOUNT_ID_PREFIX) ? String(id.dropFirst(ACCOUNT_ID_PREFIX.count)) : nil\n}\n\nfunc baseVendorId(_ id: String) -> String {\n accountLabel(of: id) != nil ? \"anthropic\" : id\n}\n\n/// The subprocess arguments that select `id` (base vendor or named account).\nfunc vendorArgs(for id: String) -> [String] {\n if let label = accountLabel(of: id) {\n return [\"--vendor\", \"anthropic\", \"--account\", label]\n }\n return [\"--vendor\", id]\n}\n\n/// One selectable entry: a base vendor or one Claude account.\nstruct MenuEntry {\n let id: String // \"cursor\", \"anthropic\", or \"anthropic@<label>\"\n let name: String // display: \"Cursor\", \"Claude · <label>\"\n}\n\n/// Apply `[ui] overview_vendors` with the same semantics as the TUI: preserve\n/// config order, omit unavailable vendors, and include every named Claude\n/// account when `anthropic` is requested.\nfunc filterOverviewEntries(_ entries: [MenuEntry], requested: [String]?) -> [MenuEntry] {\n guard let requested else { return entries }\n var result: [MenuEntry] = []\n var seen = Set<String>()\n for vendor in requested {\n for entry in entries where baseVendorId(entry.id) == vendor && !seen.contains(entry.id) {\n result.append(entry)\n seen.insert(entry.id)\n }\n }\n return result\n}\n\n/// The selectable entries, in menu order: every enabled+configured vendor,\n/// with Anthropic expanded into its named accounts (the default entry kept\n/// only per `show_default_account`). `active` stays listed even when\n/// unconfigured — same rule the per-vendor list always had.\nfunc vendorEntries(active: String) -> [MenuEntry] {\n var out: [MenuEntry] = []\n for v in VENDOR_AUTH where vendorEnabled(v) {\n if v.id == \"anthropic\" {\n let labels = claudeAccountLabels()\n let showDefault = showDefaultClaudeAccount(\n configValue: configValueTOML(\"anthropic\", \"show_default_account\"),\n hasAccounts: !labels.isEmpty)\n if showDefault && (v.id == active || vendorConfigured(v)) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n for label in labels {\n out.append(MenuEntry(id: ACCOUNT_ID_PREFIX + label, name: \"Claude · \\(label)\"))\n }\n } else if v.id == active || vendorConfigured(v) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n }\n return out\n}\n\n/// Display name for any selectable id (base vendor, account, or overview).\nfunc entryDisplayName(_ id: String) -> String {\n if id == \"overview\" { return \"Visão geral\" }\n if let label = accountLabel(of: id) { return \"Claude · \\(label)\" }\n return VENDOR_AUTH.first { $0.id == id }?.name ?? id\n}\n\n// MARK: - Which account each surface is signed in as\n//\n// Separate from the vendor entries above: those decide whose usage is *shown*,\n// these are who the Claude Desktop app and the `claude` CLI are actually signed\n// in as. Both come from `ai-usagebar account status --json`; the binary owns\n// every path and credential detail, so this side only parses and displays.\n\nstruct AccountStatus: Equatable {\n /// False when there is no Claude Desktop app on this machine. Distinct from\n /// \"no accounts saved yet\": with the app present but nothing captured, the\n /// submenu still has to appear so the user can add the first one.\n var desktopAvailable = false\n var desktopActive: String?\n var cliActive: String?\n var desktopLabels: [String] = []\n var cliLabels: [String] = []\n}\n\n/// Parse `account status --json`. Every field is optional on purpose: a Mac\n/// with no Claude Desktop app reports `\"desktop\": null`, and an older binary\n/// may not know the subcommand at all — both must degrade to \"no submenus\"\n/// rather than crash.\nfunc parseAccountStatus(_ data: Data) -> AccountStatus? {\n guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {\n return nil\n }\n func labels(_ side: Any?, _ key: String) -> [String] {\n guard let side = side as? [String: Any], let rows = side[key] as? [[String: Any]] else {\n return []\n }\n return rows.compactMap { $0[\"label\"] as? String }\n }\n func active(_ side: Any?) -> String? {\n (side as? [String: Any])?[\"active_label\"] as? String\n }\n let desktop = root[\"desktop\"], cli = root[\"cli\"]\n return AccountStatus(desktopAvailable: desktop is [String: Any],\n desktopActive: active(desktop),\n cliActive: active(cli),\n desktopLabels: labels(desktop, \"profiles\"),\n cliLabels: labels(cli, \"accounts\"))\n}\n\n/// The one dim line under the header. Empty when neither surface resolved, so\n/// the caller can hide the row entirely rather than show a bare label.\nfunc accountsSummaryLine(_ s: AccountStatus) -> String {\n var parts: [String] = []\n if !s.desktopLabels.isEmpty { parts.append(\"Desktop: \\(s.desktopActive ?? \"?\")\") }\n if !s.cliLabels.isEmpty { parts.append(\"Code: \\(s.cliActive ?? \"?\")\") }\n return parts.joined(separator: \" · \")\n}\n\n/// `-y` because the menu has already asked; without it the binary would prompt\n/// on a stdin that is not a terminal and abort.\nfunc switchArgs(label: String, desktop: Bool) -> [String] {\n [\"account\", \"switch\", label, desktop ? \"--desktop\" : \"--cli\", \"-y\"]\n}\n\n/// Adding an account is interactive — a Desktop capture waits for you to sign\n/// in, and a CLI one runs `claude` — so it goes to Terminal rather than being\n/// swallowed by a background subprocess. Single-quoted with `'` doubled out,\n/// so a label with a quote in it can't break out of the command.\nfunc addAccountScript(binary: String, label: String, desktop: Bool) -> String {\n func quote(_ s: String) -> String { \"'\" + s.replacingOccurrences(of: \"'\", with: \"'\\\\''\") + \"'\" }\n return \"#!/usr/bin/env bash\\n\"\n + \"\\(quote(binary)) account add \\(quote(label))\\(desktop ? \" --desktop\" : \"\")\\n\"\n + \"echo; read -n 1 -s -r -p 'Pressione qualquer tecla para fechar…'\\n\"\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-\\(UUID().uuidString).sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"compactShortcutEnabled\") private var compactShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n @State private var launchAtLogin = launchAgentIsInstalled()\n @State private var launchAtLoginError: String?\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker. Claude\n // accounts appear as their `anthropic@<label>` pseudo-ids, same as the\n // \"Trocar vendor\" submenu.\n private var vendors: [String] {\n var ids = VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n let labels = claudeAccountLabels()\n if let at = ids.firstIndex(of: \"anthropic\") {\n ids.insert(contentsOf: labels.map { ACCOUNT_ID_PREFIX + $0 }, at: at + 1)\n }\n return ids\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n Toggle(\"Compactar/Expandir com ⌥⌘E (atalho global)\", isOn: $compactShortcutEnabled)\n Text(\"Alterna a Visão geral entre barras e modo compacto.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Sistema\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Iniciar no login\", isOn: Binding(\n get: { launchAtLogin },\n set: { enabled in\n do {\n try setLaunchAtLogin(enabled)\n launchAtLogin = launchAgentIsInstalled()\n launchAtLoginError = nil\n } catch {\n launchAtLogin = launchAgentIsInstalled()\n launchAtLoginError = error.localizedDescription\n }\n }))\n Text(\"Instala um LaunchAgent que sobe o app ao entrar na sua conta.\")\n .font(.caption).foregroundColor(.secondary)\n if let error = launchAtLoginError {\n Text(\"Não foi possível atualizar: \\(error)\")\n .font(.caption).foregroundColor(.red)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n .onAppear { launchAtLogin = launchAgentIsInstalled() }\n }\n}\n\n// ─── Launch at login (macOS LaunchAgent) ─────────────────────────────────\n//\n// The app is an unbundled single binary, so the modern SMAppService.mainApp\n// API (which needs a bundle id) doesn't apply. A per-user LaunchAgent is the\n// portable way to start an unbundled executable at login — the same mechanism\n// install-agent.sh sets up, now toggleable from Preferences.\nlet LAUNCH_AGENT_LABEL = \"com.akitaonrails.ai-usagebar-menubar\"\n\nfunc launchAgentPlistPath() -> String {\n \"\\(NSHomeDirectory())/Library/LaunchAgents/\\(LAUNCH_AGENT_LABEL).plist\"\n}\n\nfunc launchAgentIsInstalled() -> Bool {\n FileManager.default.fileExists(atPath: launchAgentPlistPath())\n}\n\n/// Absolute path of the running executable, for the LaunchAgent to relaunch.\nfunc selfExecutablePath() -> String {\n if let p = Bundle.main.executablePath, !p.isEmpty { return p }\n let arg0 = CommandLine.arguments.first ?? \"ai-usagebar-menubar\"\n if arg0.hasPrefix(\"/\") { return arg0 }\n return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)\n .appendingPathComponent(arg0).standardized.path\n}\n\n/// Install (or remove) the login LaunchAgent by managing its plist file.\n/// Building the plist via PropertyListSerialization sidesteps the XML-escaping\n/// the shell installer has to do by hand.\n///\n/// Deliberately no `launchctl load`/`unload`: the app is already running when\n/// this is toggled, so `RunAtLoad` on load would spawn a *second* copy, and\n/// `unload` on disable would SIGTERM this very process (quitting the app the\n/// user is still using) if it happened to be the launchd-managed one. launchd\n/// loads every agent in ~/Library/LaunchAgents at the next login, so writing\n/// (or removing) the file is all that's needed for a start-at-login toggle.\nfunc launchAgentPlist(executable: String) throws -> Data {\n let plist: [String: Any] = [\n \"Label\": LAUNCH_AGENT_LABEL,\n \"ProgramArguments\": [executable],\n \"RunAtLoad\": true,\n \"ProcessType\": \"Interactive\",\n ]\n return try PropertyListSerialization.data(\n fromPropertyList: plist, format: .xml, options: 0)\n}\n\nfunc setLaunchAtLogin(_ enabled: Bool) throws {\n let path = launchAgentPlistPath()\n if enabled {\n let dir = (path as NSString).deletingLastPathComponent\n try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)\n let data = try launchAgentPlist(executable: selfExecutablePath())\n try data.write(to: URL(fileURLWithPath: path), options: .atomic)\n } else if FileManager.default.fileExists(atPath: path) {\n try FileManager.default.removeItem(atPath: path)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\nlet compactHotKeyNotification = Notification.Name(\"aiusagebar.compactHotKey\")\n\n/// Default shortcuts: `\\` (kVK_ANSI_Backslash) with Command+Option swaps the\n/// vendor; `E` with Command+Option toggles the overview's Compactar/Expandir.\n/// `[ui]` on Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet COMPACT_HOTKEY_KEYCODE = UInt32(kVK_ANSI_E)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\n/// Registered hot-key ids, carried back to us in the event so one shared C\n/// callback can tell which shortcut fired.\nlet SWAP_HOTKEY_ID = UInt32(1)\nlet COMPACT_HOTKEY_ID = UInt32(2)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n var hk = EventHotKeyID()\n GetEventParameter(event, EventParamName(kEventParamDirectObject),\n EventParamType(typeEventHotKeyID), nil,\n MemoryLayout<EventHotKeyID>.size, nil, &hk)\n NotificationCenter.default.post(\n name: hk.id == COMPACT_HOTKEY_ID ? compactHotKeyNotification : swapHotKeyNotification,\n object: nil)\n return noErr\n}\n\n/// Whether the overview status-bar title draws mini bars (vs. the compact\n/// %-text mode). \"Compactar\" forces the text mode even under the bars-count\n/// threshold. Pure + testable — the render path is not.\nfunc overviewUsesBars(count: Int, barsMax: Int, compact: Bool) -> Bool {\n !compact && count <= barsMax\n}\n\n/// Provider ids the user toggled out of the always-visible top-bar summary in\n/// Overview mode. The dropdown still lists them (unchecked + dimmed) so they can\n/// be turned back on. Persisted in UserDefaults; absent key → nothing hidden.\nfunc overviewHiddenProviders() -> Set<String> {\n Set(DEF.stringArray(forKey: \"overviewHiddenProviders\") ?? [])\n}\n\nfunc setOverviewProvider(_ id: String, hidden: Bool) {\n var hiddenSet = overviewHiddenProviders()\n if hidden { hiddenSet.insert(id) } else { hiddenSet.remove(id) }\n DEF.set(hiddenSet.sorted(), forKey: \"overviewHiddenProviders\")\n}\n\n/// The ids that survive the top-bar filter, in their original order. Pure +\n/// testable — the render path (which also drops nil snapshots) is not.\nfunc overviewVisibleIds(_ ids: [String], hidden: Set<String>) -> [String] {\n ids.filter { !hidden.contains($0) }\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nfunc hotKeyRegistrationSucceeded(_ status: OSStatus) -> Bool {\n status == noErr\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var compactHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Live watch on config.toml so external edits (a text editor, the TUI's\n /// Settings overlay, `ai-usagebar account add`) hot-reload\n /// the vendor list without restarting the app. The fd is closed by the\n /// source's cancel handler; `pendingConfigReload` coalesces an editor's\n /// burst of vnode events into a single reload.\n var configWatchSource: DispatchSourceFileSystemObject?\n var configWatchFD: CInt = -1\n var pendingConfigReload: DispatchWorkItem?\n /// A visibility checkbox changes presentation only. Its UserDefaults\n /// notification should repaint from cache without restarting the timer or\n /// launching a fresh round of provider subprocesses.\n var overviewVisibilityChangePending = false\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor/account); hidden in\n // single-vendor mode. The pool starts at the built-in vendor count and can\n // grow if account discovery adds more rows.\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n /// Overview-only: forces the status-bar title into the compact %-text mode\n /// (\"Compactar\"); while compact it reads \"Expandir\" and turns it back off.\n let compactItem = NSMenuItem(title: \"Compactar\", action: nil, keyEquivalent: \"\")\n /// Which account each surface is signed in as. One dim line under the\n /// header, plus a submenu per surface. All three stay hidden until\n /// `account status --json` answers, so an older binary that doesn't know\n /// the subcommand simply shows the menu it always did.\n let accountsInfoItem = NSMenuItem()\n let desktopAccountSubmenu = NSMenu()\n let desktopAccountItem = NSMenuItem(title: \"Claude Desktop\", action: nil, keyEquivalent: \"\")\n let cliAccountSubmenu = NSMenu()\n let cliAccountItem = NSMenuItem(title: \"Claude Code\", action: nil, keyEquivalent: \"\")\n var lastAccountStatus: AccountStatus?\n var accountStatusFetchedAt = Date.distantPast\n var accountStatusGeneration = 0\n /// A switch runs a subprocess that quits and reopens another app; both\n /// submenus grey out until it returns so it cannot be fired twice.\n var accountSwitchInFlight = false\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true, \"compactShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleCompactHotKey),\n name: compactHotKeyNotification, object: nil)\n installSwapHotKey()\n installCompactHotKey()\n installConfigWatch()\n fetchAccountStatus()\n }\n\n /// Watch config.toml and hot-reload the vendor list when it changes on disk.\n /// Editors usually save atomically (write a temp file, then rename it over\n /// the target), which unlinks the inode our descriptor points at — so a\n /// `.delete`/`.rename` event means \"re-open the path to keep watching the\n /// new file\". A plain in-place write (`>>`, some editors) fires `.write` on\n /// the same descriptor. Idempotent: always tears down the previous watch.\n func installConfigWatch() {\n removeConfigWatch()\n let path = configPathTOML()\n let fd = open(path, O_EVTONLY)\n guard fd >= 0 else {\n // No readable file yet (not created, or momentarily gone between an\n // editor's unlink and rename). Retry shortly so a config created\n // after launch — or a non-atomic save's brief gap — is still picked\n // up. ponytail: a fixed 3s retry, not exponential backoff; this only\n // spins in the rare window before the file exists.\n DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in\n self?.installConfigWatch()\n }\n return\n }\n let src = DispatchSource.makeFileSystemObjectSource(\n fileDescriptor: fd, eventMask: [.write, .extend, .delete, .rename, .attrib],\n queue: .main)\n src.setEventHandler { [weak self] in\n guard let self, let flags = self.configWatchSource?.data else { return }\n self.scheduleConfigReload()\n // The watched inode was replaced; re-arm on whatever is at the path\n // now so future edits keep firing.\n if flags.contains(.delete) || flags.contains(.rename) {\n self.installConfigWatch()\n }\n }\n src.setCancelHandler { close(fd) }\n configWatchFD = fd\n configWatchSource = src\n src.resume()\n }\n\n func removeConfigWatch() {\n configWatchSource?.cancel() // the cancel handler closes the fd\n configWatchSource = nil\n configWatchFD = -1\n }\n\n /// Coalesce the burst of vnode events a save emits (write → rename → attrib)\n /// into one reload a beat later, mirroring `settingsChanged`'s debounce.\n func scheduleConfigReload() {\n pendingConfigReload?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.configFileChanged() }\n pendingConfigReload = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)\n }\n\n /// A config.toml edit landed: rebuild the vendor submenu/ring, re-render the\n /// current view from cache so an `[ui]` tweak shows at once, then debounce a\n /// full refresh to fetch any newly-added vendor/account. Reads are all fresh\n /// from disk (nothing caches config.toml), so no invalidation is needed.\n @objc func configFileChanged() {\n rebuildVendorSubmenu()\n // A new [[anthropic.accounts]] entry changes the Claude Code list.\n fetchAccountStatus()\n if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n guard removeSwapHotKey() else {\n // The old registration is still live; reflect that instead of\n // showing a disabled toggle that continues intercepting the key.\n DEF.set(true, forKey: \"swapShortcutEnabled\")\n return\n }\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n guard installHotKeyHandlerOnce() else {\n disableFailedShortcut(\"swapShortcutEnabled\", name: \"⌥⌘\\\\\")\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n return\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: SWAP_HOTKEY_ID) // 'AIBB'\n var ref: EventHotKeyRef?\n let status = RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &ref)\n guard hotKeyRegistrationSucceeded(status), let ref else {\n if let ref { UnregisterEventHotKey(ref) }\n disableFailedShortcut(\"swapShortcutEnabled\", name: \"⌥⌘\\\\\", status: status)\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n return\n }\n swapHotKeyRef = ref\n }\n\n /// (Re)register the global ⌥⌘E Compactar/Expandir hot key to match the\n /// `compactShortcutEnabled` preference. Idempotent, mirroring\n /// `installSwapHotKey` — including the painted-in hint (a native\n /// keyEquivalent would fire a second time while the menu is open, double-\n /// toggling on one press).\n func installCompactHotKey() {\n guard removeCompactHotKey() else {\n DEF.set(true, forKey: \"compactShortcutEnabled\")\n return\n }\n updateCompactItemTitle()\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else { return }\n guard installHotKeyHandlerOnce() else {\n disableFailedShortcut(\"compactShortcutEnabled\", name: \"⌥⌘E\")\n updateCompactItemTitle()\n return\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: COMPACT_HOTKEY_ID)\n var ref: EventHotKeyRef?\n let status = RegisterEventHotKey(\n COMPACT_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &ref)\n guard hotKeyRegistrationSucceeded(status), let ref else {\n if let ref { UnregisterEventHotKey(ref) }\n disableFailedShortcut(\"compactShortcutEnabled\", name: \"⌥⌘E\", status: status)\n updateCompactItemTitle()\n return\n }\n compactHotKeyRef = ref\n }\n\n /// Compactar ↔ Expandir label plus the dimmed ⌥⌘E hint when the global\n /// shortcut is on. Shared by the overview render and the hot-key installer.\n func updateCompactItemTitle() {\n let label = DEF.bool(forKey: \"overviewCompact\") ? \"Expandir\" : \"Compactar\"\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else {\n compactItem.attributedTitle = NSAttributedString(string: label)\n return\n }\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(string: \"\\(label) \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘E\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n compactItem.attributedTitle = s\n }\n\n /// One Carbon handler serves every hot key; the event's EventHotKeyID\n /// says which one fired.\n private func installHotKeyHandlerOnce() -> Bool {\n guard !swapHotKeyHandlerInstalled else { return true }\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n let status = InstallEventHandler(\n GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: global hot-key handler registration failed (OSStatus %d)\", status)\n return false\n }\n swapHotKeyHandlerInstalled = true\n return true\n }\n\n private func disableFailedShortcut(_ key: String, name: String,\n status: OSStatus? = nil) {\n if let status {\n NSLog(\"ai-usagebar: global shortcut %@ registration failed (OSStatus %d)\",\n name, status)\n } else {\n NSLog(\"ai-usagebar: global shortcut %@ registration failed\", name)\n }\n // Keep the visible preference honest when another app already owns the\n // shortcut or Carbon refuses the registration.\n DEF.set(false, forKey: key)\n }\n\n @discardableResult\n func removeSwapHotKey() -> Bool {\n if let ref = swapHotKeyRef {\n let status = UnregisterEventHotKey(ref)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: could not unregister ⌥⌘\\\\ (OSStatus %d)\", status)\n return false\n }\n swapHotKeyRef = nil\n }\n return true\n }\n\n @discardableResult\n private func removeCompactHotKey() -> Bool {\n if let ref = compactHotKeyRef {\n let status = UnregisterEventHotKey(ref)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: could not unregister ⌥⌘E (OSStatus %d)\", status)\n return false\n }\n compactHotKeyRef = nil\n }\n return true\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// ⌥⌘E: toggle Compactar/Expandir. Overview-only — flipping a hidden\n /// preference from another view would be invisible and confusing.\n @objc func handleCompactHotKey() {\n guard VENDOR == \"overview\" else { return }\n toggleCompact()\n }\n\n /// The swap-shortcut ring: every configured entry (vendors *and* Claude\n /// accounts) plus the synthetic \"overview\" target, so ⌥⌘\\ cycles them all.\n func swapCycleIds() -> [String] {\n var ids = vendorEntries(active: VENDOR).map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // Reads as a sub-line of the header, so it sits above the usage rows.\n accountsInfoItem.isEnabled = false\n accountsInfoItem.isHidden = true\n menu.addItem(accountsInfoItem)\n // One hidden slot per built-in vendor for Overview mode. Named accounts\n // can take the total higher; renderOverview grows the pool on demand.\n for _ in 0..<VENDOR_AUTH.count {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n // First item after the usage rows, Overview-only (hidden elsewhere).\n compactItem.action = #selector(toggleCompact)\n compactItem.target = self\n compactItem.isHidden = true\n menu.addItem(compactItem)\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n for (item, submenu) in [(desktopAccountItem, desktopAccountSubmenu),\n (cliAccountItem, cliAccountSubmenu)] {\n item.submenu = submenu\n item.isHidden = true\n menu.addItem(item)\n }\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n // Catches a switch made elsewhere (a terminal, claude-acc) without\n // polling for state that changes at most a few times a day.\n menu.delegate = self\n statusItem.menu = menu\n }\n\n func menuWillOpen(_ menu: NSMenu) {\n if Date().timeIntervalSince(accountStatusFetchedAt) >= 5 { fetchAccountStatus() }\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n /// Flip the overview's compact mode; the UserDefaults observer re-renders,\n /// which also relabels the item (Compactar ↔ Expandir).\n @objc func toggleCompact() {\n DEF.set(!DEF.bool(forKey: \"overviewCompact\"), forKey: \"overviewCompact\")\n }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n if overviewVisibilityChangePending {\n overviewVisibilityChangePending = false\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov) }\n return\n }\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n if DEF.bool(forKey: \"compactShortcutEnabled\") != (compactHotKeyRef != nil) {\n installCompactHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = entryDisplayName(VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: vendor) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n // vendorEntries(active: \"\") keeps only configured entries — the\n // active-vendor courtesy slot has no place in an all-vendors sweep.\n let entries = filterOverviewEntries(vendorEntries(active: \"\"),\n requested: configuredOverviewVendorIds())\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in entries {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: v.id) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: baseVendorId(v.id))\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// Compact bar label for a vendor name. Usually the first word; for a\n /// Claude account (\"Claude · gmail\") the account label is the part that\n /// distinguishes entries, so use it instead of the shared \"Claude\".\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let base = name.range(of: \" · \").map { String(name[$0.upperBound...]) }\n ?? String(name.split(separator: \" \").first ?? Substring(name))\n return base.count <= maxLen ? base : String(base.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 4) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n // Providers toggled off in the dropdown are dropped from the always-on\n // top summary (but still listed there so they can be re-enabled).\n let hidden = overviewHiddenProviders()\n let visible = Set(overviewVisibleIds(items.map { $0.id }, hidden: hidden))\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String, reset: String?)] =\n items.compactMap {\n guard visible.contains($0.id), let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\", nil) }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\", h.reset.flatMap(shortReset))\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 4\n let compact = DEF.bool(forKey: \"overviewCompact\")\n let t = NSMutableAttributedString()\n if overviewUsesBars(count: heads.count, barsMax: barsMax, compact: compact) {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n // Entry order, not worst-first: entries are provider-grouped\n // (standard Claude, then its accounts, then the other vendors), and\n // stable positions beat a pct sort that reshuffles labels on every\n // refresh.\n for (i, e) in heads.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n }\n if heads.count > maxN { t.append(run(\" +\\(heads.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n ensureOverviewRowCapacity(items.count)\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n let hidden = overviewHiddenProviders()\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to toggle whether this provider shows in the top-bar\n // summary. Checkmark = shown; unchecked + dimmed = hidden from the\n // bar (still listed here so it can be turned back on). Jump-to-vendor\n // moved to the \"Trocar vendor\" submenu and ⌥⌘\\.\n let isHidden = hidden.contains(item.id)\n it.state = isHidden ? .off : .on\n it.representedObject = item.id\n it.target = self\n it.action = #selector(toggleOverviewProvider(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n // Grey the whole row while hidden, so \"off\" reads at a glance.\n if isHidden {\n a.addAttribute(.foregroundColor, value: NSColor.tertiaryLabelColor,\n range: NSRange(location: 0, length: a.length))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n compactItem.isHidden = false\n updateCompactItemTitle()\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n /// Add overview row slots before the single-vendor rows when newly\n /// discovered Claude accounts outgrow the initial built-in-vendor pool.\n private func ensureOverviewRowCapacity(_ count: Int) {\n guard count > overviewRows.count,\n let menu = statusItem.menu,\n let firstDetail = rows[\"session\"] else { return }\n while overviewRows.count < count {\n let item = NSMenuItem()\n item.isHidden = true\n menu.insertItem(item, at: menu.index(of: firstDetail))\n overviewRows.append(item)\n }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: baseVendorId(VENDOR)) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n // With several Claude accounts the plan alone (\"Claude Max 20x\") no\n // longer says WHICH account this is — suffix the label.\n let plan = s.plan.isEmpty ? \"AI Usage\" : s.plan\n let header = accountLabel(of: VENDOR).map { \"\\(plan) · \\($0)\" } ?? plan\n headerItem.attributedTitle = run(header, .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let entries = vendorEntries(active: active)\n if entries.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in entries {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n /// Ask the binary who each surface is signed in as. Same off-main shape as\n /// `refresh()`; failures leave `lastAccountStatus` alone so a transient\n /// hiccup doesn't blank a working menu.\n func fetchAccountStatus() {\n guard let bin = resolveBinary(\"ai-usagebar\") else { return }\n accountStatusFetchedAt = Date()\n accountStatusGeneration += 1\n let generation = accountStatusGeneration\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = [\"account\", \"status\", \"--json\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var data = Data()\n do {\n try p.run()\n data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n } catch {\n watchdog.cancel()\n return\n }\n watchdog.cancel()\n guard let status = parseAccountStatus(data) else { return }\n DispatchQueue.main.async {\n guard let me = self, generation == me.accountStatusGeneration else { return }\n me.applyAccountStatus(status)\n }\n }\n }\n\n func applyAccountStatus(_ status: AccountStatus) {\n lastAccountStatus = status\n renderAccountMenus()\n }\n\n func renderAccountMenus() {\n // Nil means the binary predates the subcommand: leave the menu exactly\n // as it was before this feature existed.\n guard let status = lastAccountStatus else {\n accountsInfoItem.isHidden = true\n desktopAccountItem.isHidden = true\n cliAccountItem.isHidden = true\n return\n }\n let line = accountsSummaryLine(status)\n accountsInfoItem.isHidden = line.isEmpty\n accountsInfoItem.attributedTitle = run(line, .secondaryLabelColor)\n\n fill(desktopAccountSubmenu, status.desktopLabels, status.desktopActive,\n #selector(switchDesktopAccount(_:)))\n fill(cliAccountSubmenu, status.cliLabels, status.cliActive,\n #selector(switchCliAccount(_:)))\n // Both stay visible with an empty list — that is when \"Adicionar\n // conta…\" matters most. Only a machine with no Claude Desktop app at\n // all loses its submenu.\n desktopAccountItem.isHidden = !status.desktopAvailable\n cliAccountItem.isHidden = false\n desktopAccountItem.isEnabled = !accountSwitchInFlight\n cliAccountItem.isEnabled = !accountSwitchInFlight\n }\n\n private func fill(_ submenu: NSMenu, _ labels: [String], _ active: String?, _ action: Selector) {\n submenu.removeAllItems()\n for label in labels {\n let it = NSMenuItem(title: label, action: action, keyEquivalent: \"\")\n it.target = self\n it.representedObject = label\n it.state = (label == active) ? .on : .off\n it.isEnabled = !accountSwitchInFlight && label != active\n submenu.addItem(it)\n }\n submenu.addItem(.separator())\n let add = NSMenuItem(title: \"Adicionar conta…\",\n action: #selector(addAccount(_:)), keyEquivalent: \"\")\n add.target = self\n add.representedObject = (submenu === desktopAccountSubmenu)\n add.isEnabled = !accountSwitchInFlight\n submenu.addItem(add)\n }\n\n /// Ask for a label, then hand the interactive part to Terminal: a Desktop\n /// capture waits for a browser sign-in, a CLI one runs `claude`. Neither\n /// belongs in a background subprocess the user cannot see or answer.\n @objc func addAccount(_ sender: NSMenuItem) {\n guard let desktop = sender.representedObject as? Bool,\n let bin = resolveBinary(\"ai-usagebar\") else { return }\n let alert = NSAlert()\n alert.messageText = desktop ? \"Adicionar conta do Claude Desktop\"\n : \"Adicionar conta do Claude Code\"\n alert.informativeText = desktop\n ? \"Escolha um nome. O app será fechado e reaberto na tela de login para você \"\n + \"entrar com a conta nova; a atual é restaurada se você cancelar.\"\n : \"Escolha um nome. O `claude` abrirá no Terminal para você entrar; seu login \"\n + \"padrão não é alterado.\"\n let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 220, height: 24))\n field.placeholderString = \"trabalho\"\n alert.accessoryView = field\n alert.addButton(withTitle: \"Continuar\")\n alert.addButton(withTitle: \"Cancelar\")\n NSApp.activate(ignoringOtherApps: true)\n alert.window.initialFirstResponder = field\n guard alert.runModal() == .alertFirstButtonReturn else { return }\n let label = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)\n guard !label.isEmpty else { return }\n runInTerminal(addAccountScript(binary: bin, label: label, desktop: desktop))\n }\n\n /// Quits and reopens Claude.app, so confirm first — an unsent message or an\n /// in-flight Cowork run would go with it. The CLI switch has no visible\n /// side effect and needs no prompt.\n @objc func switchDesktopAccount(_ sender: NSMenuItem) {\n guard let label = sender.representedObject as? String else { return }\n let alert = NSAlert()\n alert.messageText = \"Trocar a conta do Claude Desktop para «\\(label)»?\"\n alert.informativeText = \"O app será fechado e reaberto. Seu histórico local é \"\n + \"mesclado nessa conta antes da troca, e uma cópia de segurança é gravada.\"\n alert.addButton(withTitle: \"Trocar e reiniciar\")\n alert.addButton(withTitle: \"Cancelar\")\n NSApp.activate(ignoringOtherApps: true)\n guard alert.runModal() == .alertFirstButtonReturn else { return }\n runAccountSwitch(label: label, desktop: true)\n }\n\n @objc func switchCliAccount(_ sender: NSMenuItem) {\n guard let label = sender.representedObject as? String else { return }\n runAccountSwitch(label: label, desktop: false)\n }\n\n private func runAccountSwitch(label: String, desktop: Bool) {\n guard let bin = resolveBinary(\"ai-usagebar\"), !accountSwitchInFlight else { return }\n accountSwitchInFlight = true\n renderAccountMenus()\n let args = switchArgs(label: label, desktop: desktop)\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = args\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = pipe\n let failure: String?\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n if p.terminationStatus != 0 {\n let detail = String(decoding: data, as: UTF8.self)\n .trimmingCharacters(in: .whitespacesAndNewlines)\n failure = detail.isEmpty\n ? \"ai-usagebar terminou com status \\(p.terminationStatus).\"\n : String(detail.prefix(2_000))\n } else {\n failure = nil\n }\n } catch {\n failure = \"Não foi possível iniciar ai-usagebar: \\(error.localizedDescription)\"\n }\n DispatchQueue.main.async {\n guard let me = self else { return }\n me.accountSwitchInFlight = false\n me.fetchAccountStatus()\n if let failure {\n let alert = NSAlert()\n alert.alertStyle = .warning\n alert.messageText = \"Não foi possível trocar a conta\"\n alert.informativeText = failure\n alert.addButton(withTitle: \"OK\")\n NSApp.activate(ignoringOtherApps: true)\n alert.runModal()\n } else {\n // The usage numbers belong to whoever is signed in now.\n me.refresh()\n }\n }\n }\n }\n\n /// Toggle whether a provider appears in the Overview top-bar summary, from\n /// its dropdown row. The UserDefaults observer re-renders from the last\n /// fetch and consumes this presentation-only change without re-fetching.\n @objc func toggleOverviewProvider(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n overviewVisibilityChangePending = true\n setOverviewProvider(id, hidden: !overviewHiddenProviders().contains(id))\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n accountsInfoItem.isHidden = true\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "ab6163f9b69d7190fce0fbe3b504ac362f709679": {"t": 1785750114.5852954, "n": 119710, "h": "eaea24fdf9e43e8f", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\n/// Squeeze a countdown (\"4d 1h\", \"2h 05m\", \"now\") down to its leading unit for\n/// the overview status-bar title, where every character fights for space:\n/// \"4d 1h\" → \"4d\", \"2h 05m\" → \"2h\", \"0h 05m\" → \"5m\". Missing/em-dash → nil.\nfunc shortReset(_ r: String) -> String? {\n guard !r.isEmpty, r != \"—\" else { return nil }\n let parts = r.split(separator: \" \")\n guard let first = parts.first else { return nil }\n if first == \"0h\", parts.count > 1 {\n let m = parts[1].drop(while: { $0 == \"0\" })\n return m == \"m\" ? \"0m\" : String(m)\n }\n return String(first)\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\n/// Read a TOML array of quoted strings. This intentionally stays small (the\n/// native app is a single dependency-free Swift file), but accepts multiline\n/// arrays and rejects malformed values instead of silently widening Overview.\nfunc tomlStringArrayInText(_ text: String, section: String, key: String) -> [String]? {\n func withoutComment(_ line: String) -> String {\n var result = \"\"\n var quote: Character?\n var escaped = false\n for ch in line {\n if let active = quote {\n result.append(ch)\n if active == \"\\\"\", escaped {\n escaped = false\n } else if active == \"\\\"\", ch == \"\\\\\" {\n escaped = true\n } else if ch == active {\n quote = nil\n }\n } else if ch == \"#\" {\n break\n } else {\n result.append(ch)\n if ch == \"\\\"\" || ch == \"'\" { quote = ch }\n }\n }\n return result\n }\n\n func hasClosingBracket(_ value: String) -> Bool {\n var quote: Character?\n var escaped = false\n for ch in value {\n if let active = quote {\n if active == \"\\\"\", escaped {\n escaped = false\n } else if active == \"\\\"\", ch == \"\\\\\" {\n escaped = true\n } else if ch == active {\n quote = nil\n }\n } else if ch == \"\\\"\" || ch == \"'\" {\n quote = ch\n } else if ch == \"]\" {\n return true\n }\n }\n return false\n }\n\n var inSection = false\n var rawValue: String?\n for rawLine in text.components(separatedBy: .newlines) {\n let line = withoutComment(rawLine).trimmingCharacters(in: .whitespaces)\n if let current = rawValue {\n let joined = current + \" \" + line\n rawValue = joined\n if hasClosingBracket(joined) { break }\n continue\n }\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.isEmpty else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n guard value.hasPrefix(\"[\") else { return nil }\n rawValue = value\n if hasClosingBracket(value) { break }\n }\n\n guard let raw = rawValue, hasClosingBracket(raw) else { return nil }\n let chars = Array(raw)\n guard chars.first == \"[\" else { return nil }\n var result: [String] = []\n var i = 1\n\n func skipSpace() {\n while i < chars.count && chars[i].isWhitespace { i += 1 }\n }\n\n while true {\n skipSpace()\n guard i < chars.count else { return nil }\n if chars[i] == \"]\" {\n i += 1\n skipSpace()\n return i == chars.count ? result : nil\n }\n let quote = chars[i]\n guard quote == \"\\\"\" || quote == \"'\" else { return nil }\n i += 1\n var value = \"\"\n var closed = false\n while i < chars.count {\n let ch = chars[i]\n i += 1\n if ch == quote {\n closed = true\n break\n }\n if quote == \"\\\"\", ch == \"\\\\\", i < chars.count {\n value.append(chars[i])\n i += 1\n } else {\n value.append(ch)\n }\n }\n guard closed else { return nil }\n result.append(value)\n skipSpace()\n guard i < chars.count else { return nil }\n if chars[i] == \",\" {\n i += 1\n continue\n }\n guard chars[i] == \"]\" else { return nil }\n }\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc configuredOverviewVendorIds() -> [String]? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlStringArrayInText(text, section: \"ui\", key: \"overview_vendors\")\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n// ── Anthropic multi-account ────────────────────────────────────────────────\n// Mirrors the Rust side (`src/config.rs`): explicit `[[anthropic.accounts]]`\n// entries plus subdirectories of `[anthropic] accounts_dir`, explicit labels\n// winning on a clash. Each account is a\n// selectable entry with the pseudo-id `anthropic@<label>`, fetched by the\n// binary as `--vendor anthropic --account <label>` — the binary resolves the\n// account's credentials itself (file or its own Keychain item), so the app\n// never needs to know where they live.\n\n/// `[[anthropic.accounts]]` labels, in file order. Pure text scan, like the\n/// rest of this app's TOML reading: a `[[anthropic.accounts]]` header opens a\n/// block, the first `label = \"…\"` inside it counts.\nfunc anthropicAccountLabels(inTOML text: String) -> [String] {\n var labels: [String] = []\n var inBlock = false\n for rawLine in text.components(separatedBy: .newlines) {\n let line = rawLine.trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inBlock = line == \"[[anthropic.accounts]]\"\n continue\n }\n guard inBlock, line.hasPrefix(\"label\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == \"label\" else { continue }\n var value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n value = String(content[..<end])\n }\n if !value.isEmpty {\n labels.append(value)\n inBlock = false // one label per block\n }\n }\n return labels\n}\n\n/// Immediate subdirectories of `dir`, sorted — the same discovery rule as the\n/// Rust `accounts_dir` scan. Credentials may be file- or Keychain-backed.\nfunc discoverAccountLabels(inDir dir: String) -> [String] {\n let fm = FileManager.default\n guard let names = try? fm.contentsOfDirectory(atPath: dir) else { return [] }\n return names.filter { name in\n var isDir: ObjCBool = false\n let sub = \"\\(dir)/\\(name)\"\n return fm.fileExists(atPath: sub, isDirectory: &isDir) && isDir.boolValue\n }.sorted()\n}\n\n/// Explicit labels first (file order), then discovered ones that don't clash.\nfunc mergedAccountLabels(explicit: [String], discovered: [String]) -> [String] {\n explicit + discovered.filter { !explicit.contains($0) }\n}\n\n/// All configured Claude account labels, from the same config the binary reads.\nfunc claudeAccountLabels() -> [String] {\n guard let text = try? String(contentsOfFile: configPathTOML(), encoding: .utf8) else {\n return []\n }\n let explicit = anthropicAccountLabels(inTOML: text)\n var discovered: [String] = []\n if let dir = tomlValueInText(text, section: \"anthropic\", key: \"accounts_dir\"), !dir.isEmpty {\n discovered = discoverAccountLabels(inDir: NSString(string: dir).expandingTildeInPath)\n }\n return mergedAccountLabels(explicit: explicit, discovered: discovered)\n}\n\n/// Rust semantics (`show_default_account`): `false` hides the default\n/// (unnamed) Claude entry, but is ignored when there are no named accounts,\n/// so Anthropic never loses its only entry.\nfunc showDefaultClaudeAccount(configValue: String?, hasAccounts: Bool) -> Bool {\n guard hasAccounts else { return true }\n return configValue != \"false\"\n}\n\n/// Pseudo-id mapping: `anthropic@<label>` selects a named account.\nlet ACCOUNT_ID_PREFIX = \"anthropic@\"\n\nfunc accountLabel(of id: String) -> String? {\n id.hasPrefix(ACCOUNT_ID_PREFIX) ? String(id.dropFirst(ACCOUNT_ID_PREFIX.count)) : nil\n}\n\nfunc baseVendorId(_ id: String) -> String {\n accountLabel(of: id) != nil ? \"anthropic\" : id\n}\n\n/// The subprocess arguments that select `id` (base vendor or named account).\nfunc vendorArgs(for id: String) -> [String] {\n if let label = accountLabel(of: id) {\n return [\"--vendor\", \"anthropic\", \"--account\", label]\n }\n return [\"--vendor\", id]\n}\n\n/// One selectable entry: a base vendor or one Claude account.\nstruct MenuEntry {\n let id: String // \"cursor\", \"anthropic\", or \"anthropic@<label>\"\n let name: String // display: \"Cursor\", \"Claude · <label>\"\n}\n\n/// Apply `[ui] overview_vendors` with the same semantics as the TUI: preserve\n/// config order, omit unavailable vendors, and include every named Claude\n/// account when `anthropic` is requested.\nfunc filterOverviewEntries(_ entries: [MenuEntry], requested: [String]?) -> [MenuEntry] {\n guard let requested else { return entries }\n var result: [MenuEntry] = []\n var seen = Set<String>()\n for vendor in requested {\n for entry in entries where baseVendorId(entry.id) == vendor && !seen.contains(entry.id) {\n result.append(entry)\n seen.insert(entry.id)\n }\n }\n return result\n}\n\n/// The selectable entries, in menu order: every enabled+configured vendor,\n/// with Anthropic expanded into its named accounts (the default entry kept\n/// only per `show_default_account`). `active` stays listed even when\n/// unconfigured — same rule the per-vendor list always had.\nfunc vendorEntries(active: String) -> [MenuEntry] {\n var out: [MenuEntry] = []\n for v in VENDOR_AUTH where vendorEnabled(v) {\n if v.id == \"anthropic\" {\n let labels = claudeAccountLabels()\n let showDefault = showDefaultClaudeAccount(\n configValue: configValueTOML(\"anthropic\", \"show_default_account\"),\n hasAccounts: !labels.isEmpty)\n if showDefault && (v.id == active || vendorConfigured(v)) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n for label in labels {\n out.append(MenuEntry(id: ACCOUNT_ID_PREFIX + label, name: \"Claude · \\(label)\"))\n }\n } else if v.id == active || vendorConfigured(v) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n }\n return out\n}\n\n/// Display name for any selectable id (base vendor, account, or overview).\nfunc entryDisplayName(_ id: String) -> String {\n if id == \"overview\" { return \"Visão geral\" }\n if let label = accountLabel(of: id) { return \"Claude · \\(label)\" }\n return VENDOR_AUTH.first { $0.id == id }?.name ?? id\n}\n\n// MARK: - Which account each surface is signed in as\n//\n// Separate from the vendor entries above: those decide whose usage is *shown*,\n// these are who the Claude Desktop app and the `claude` CLI are actually signed\n// in as. Both come from `ai-usagebar account status --json`; the binary owns\n// every path and credential detail, so this side only parses and displays.\n\nstruct AccountStatus: Equatable {\n /// False when there is no Claude Desktop app on this machine. Distinct from\n /// \"no accounts saved yet\": with the app present but nothing captured, the\n /// submenu still has to appear so the user can add the first one.\n var desktopAvailable = false\n var desktopActive: String?\n var cliActive: String?\n var desktopLabels: [String] = []\n var cliLabels: [String] = []\n}\n\n/// Parse `account status --json`. Every field is optional on purpose: a Mac\n/// with no Claude Desktop app reports `\"desktop\": null`, and an older binary\n/// may not know the subcommand at all — both must degrade to \"no submenus\"\n/// rather than crash.\nfunc parseAccountStatus(_ data: Data) -> AccountStatus? {\n guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {\n return nil\n }\n func labels(_ side: Any?, _ key: String) -> [String] {\n guard let side = side as? [String: Any], let rows = side[key] as? [[String: Any]] else {\n return []\n }\n return rows.compactMap { $0[\"label\"] as? String }\n }\n func active(_ side: Any?) -> String? {\n (side as? [String: Any])?[\"active_label\"] as? String\n }\n let desktop = root[\"desktop\"], cli = root[\"cli\"]\n return AccountStatus(desktopAvailable: desktop is [String: Any],\n desktopActive: active(desktop),\n cliActive: active(cli),\n desktopLabels: labels(desktop, \"profiles\"),\n cliLabels: labels(cli, \"accounts\"))\n}\n\n/// The one dim line under the header. Empty when neither surface resolved, so\n/// the caller can hide the row entirely rather than show a bare label.\nfunc accountsSummaryLine(_ s: AccountStatus) -> String {\n var parts: [String] = []\n if !s.desktopLabels.isEmpty { parts.append(\"Desktop: \\(s.desktopActive ?? \"?\")\") }\n if !s.cliLabels.isEmpty { parts.append(\"Code: \\(s.cliActive ?? \"?\")\") }\n return parts.joined(separator: \" · \")\n}\n\n/// `-y` because the menu has already asked; without it the binary would prompt\n/// on a stdin that is not a terminal and abort.\nfunc switchArgs(label: String, desktop: Bool) -> [String] {\n [\"account\", \"switch\", label, desktop ? \"--desktop\" : \"--cli\", \"-y\"]\n}\n\n/// Adding an account is interactive — a Desktop capture waits for you to sign\n/// in, and a CLI one runs `claude` — so it goes to Terminal rather than being\n/// swallowed by a background subprocess. Single-quoted with `'` doubled out,\n/// so a label with a quote in it can't break out of the command.\nfunc addAccountScript(binary: String, label: String, desktop: Bool) -> String {\n func quote(_ s: String) -> String { \"'\" + s.replacingOccurrences(of: \"'\", with: \"'\\\\''\") + \"'\" }\n return \"#!/usr/bin/env bash\\n\"\n + \"\\(quote(binary)) account add \\(quote(label))\\(desktop ? \" --desktop\" : \"\")\\n\"\n + \"echo; read -n 1 -s -r -p 'Pressione qualquer tecla para fechar…'\\n\"\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-\\(UUID().uuidString).sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"compactShortcutEnabled\") private var compactShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n @State private var launchAtLogin = launchAgentIsInstalled()\n @State private var launchAtLoginError: String?\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker. Claude\n // accounts appear as their `anthropic@<label>` pseudo-ids, same as the\n // \"Trocar vendor\" submenu.\n private var vendors: [String] {\n var ids = VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n let labels = claudeAccountLabels()\n if let at = ids.firstIndex(of: \"anthropic\") {\n ids.insert(contentsOf: labels.map { ACCOUNT_ID_PREFIX + $0 }, at: at + 1)\n }\n return ids\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n Toggle(\"Compactar/Expandir com ⌥⌘E (atalho global)\", isOn: $compactShortcutEnabled)\n Text(\"Alterna a Visão geral entre barras e modo compacto.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Sistema\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Iniciar no login\", isOn: Binding(\n get: { launchAtLogin },\n set: { enabled in\n do {\n try setLaunchAtLogin(enabled)\n launchAtLogin = launchAgentIsInstalled()\n launchAtLoginError = nil\n } catch {\n launchAtLogin = launchAgentIsInstalled()\n launchAtLoginError = error.localizedDescription\n }\n }))\n Text(\"Instala um LaunchAgent que sobe o app ao entrar na sua conta.\")\n .font(.caption).foregroundColor(.secondary)\n if let error = launchAtLoginError {\n Text(\"Não foi possível atualizar: \\(error)\")\n .font(.caption).foregroundColor(.red)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n .onAppear { launchAtLogin = launchAgentIsInstalled() }\n }\n}\n\n// ─── Launch at login (macOS LaunchAgent) ─────────────────────────────────\n//\n// The app is an unbundled single binary, so the modern SMAppService.mainApp\n// API (which needs a bundle id) doesn't apply. A per-user LaunchAgent is the\n// portable way to start an unbundled executable at login — the same mechanism\n// install-agent.sh sets up, now toggleable from Preferences.\nlet LAUNCH_AGENT_LABEL = \"com.akitaonrails.ai-usagebar-menubar\"\n\nfunc launchAgentPlistPath() -> String {\n \"\\(NSHomeDirectory())/Library/LaunchAgents/\\(LAUNCH_AGENT_LABEL).plist\"\n}\n\nfunc launchAgentIsInstalled() -> Bool {\n FileManager.default.fileExists(atPath: launchAgentPlistPath())\n}\n\n/// Absolute path of the running executable, for the LaunchAgent to relaunch.\nfunc selfExecutablePath() -> String {\n if let p = Bundle.main.executablePath, !p.isEmpty { return p }\n let arg0 = CommandLine.arguments.first ?? \"ai-usagebar-menubar\"\n if arg0.hasPrefix(\"/\") { return arg0 }\n return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)\n .appendingPathComponent(arg0).standardized.path\n}\n\n/// Install (or remove) the login LaunchAgent by managing its plist file.\n/// Building the plist via PropertyListSerialization sidesteps the XML-escaping\n/// the shell installer has to do by hand.\n///\n/// Deliberately no `launchctl load`/`unload`: the app is already running when\n/// this is toggled, so `RunAtLoad` on load would spawn a *second* copy, and\n/// `unload` on disable would SIGTERM this very process (quitting the app the\n/// user is still using) if it happened to be the launchd-managed one. launchd\n/// loads every agent in ~/Library/LaunchAgents at the next login, so writing\n/// (or removing) the file is all that's needed for a start-at-login toggle.\nfunc launchAgentPlist(executable: String) throws -> Data {\n let plist: [String: Any] = [\n \"Label\": LAUNCH_AGENT_LABEL,\n \"ProgramArguments\": [executable],\n \"RunAtLoad\": true,\n \"ProcessType\": \"Interactive\",\n ]\n return try PropertyListSerialization.data(\n fromPropertyList: plist, format: .xml, options: 0)\n}\n\nfunc setLaunchAtLogin(_ enabled: Bool) throws {\n let path = launchAgentPlistPath()\n if enabled {\n let dir = (path as NSString).deletingLastPathComponent\n try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)\n let data = try launchAgentPlist(executable: selfExecutablePath())\n try data.write(to: URL(fileURLWithPath: path), options: .atomic)\n } else if FileManager.default.fileExists(atPath: path) {\n try FileManager.default.removeItem(atPath: path)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\nlet compactHotKeyNotification = Notification.Name(\"aiusagebar.compactHotKey\")\n\n/// Default shortcuts: `\\` (kVK_ANSI_Backslash) with Command+Option swaps the\n/// vendor; `E` with Command+Option toggles the overview's Compactar/Expandir.\n/// `[ui]` on Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet COMPACT_HOTKEY_KEYCODE = UInt32(kVK_ANSI_E)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\n/// Registered hot-key ids, carried back to us in the event so one shared C\n/// callback can tell which shortcut fired.\nlet SWAP_HOTKEY_ID = UInt32(1)\nlet COMPACT_HOTKEY_ID = UInt32(2)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n var hk = EventHotKeyID()\n GetEventParameter(event, EventParamName(kEventParamDirectObject),\n EventParamType(typeEventHotKeyID), nil,\n MemoryLayout<EventHotKeyID>.size, nil, &hk)\n NotificationCenter.default.post(\n name: hk.id == COMPACT_HOTKEY_ID ? compactHotKeyNotification : swapHotKeyNotification,\n object: nil)\n return noErr\n}\n\n/// Whether the overview status-bar title draws mini bars (vs. the compact\n/// %-text mode). \"Compactar\" forces the text mode even under the bars-count\n/// threshold. Pure + testable — the render path is not.\nfunc overviewUsesBars(count: Int, barsMax: Int, compact: Bool) -> Bool {\n !compact && count <= barsMax\n}\n\n/// Provider ids the user toggled out of the always-visible top-bar summary in\n/// Overview mode. The dropdown still lists them (unchecked + dimmed) so they can\n/// be turned back on. Persisted in UserDefaults; absent key → nothing hidden.\nfunc overviewHiddenProviders() -> Set<String> {\n Set(DEF.stringArray(forKey: \"overviewHiddenProviders\") ?? [])\n}\n\nfunc setOverviewProvider(_ id: String, hidden: Bool) {\n var hiddenSet = overviewHiddenProviders()\n if hidden { hiddenSet.insert(id) } else { hiddenSet.remove(id) }\n DEF.set(hiddenSet.sorted(), forKey: \"overviewHiddenProviders\")\n}\n\n/// The ids that survive the top-bar filter, in their original order. Pure +\n/// testable — the render path (which also drops nil snapshots) is not.\nfunc overviewVisibleIds(_ ids: [String], hidden: Set<String>) -> [String] {\n ids.filter { !hidden.contains($0) }\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nfunc hotKeyRegistrationSucceeded(_ status: OSStatus) -> Bool {\n status == noErr\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var compactHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Live watch on config.toml so external edits (a text editor, the TUI's\n /// Settings overlay, `ai-usagebar account add`) hot-reload\n /// the vendor list without restarting the app. The fd is closed by the\n /// source's cancel handler; `pendingConfigReload` coalesces an editor's\n /// burst of vnode events into a single reload.\n var configWatchSource: DispatchSourceFileSystemObject?\n var configWatchFD: CInt = -1\n var pendingConfigReload: DispatchWorkItem?\n /// A visibility checkbox changes presentation only. Its UserDefaults\n /// notification should repaint from cache without restarting the timer or\n /// launching a fresh round of provider subprocesses.\n var overviewVisibilityChangePending = false\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor/account); hidden in\n // single-vendor mode. The pool starts at the built-in vendor count and can\n // grow if account discovery adds more rows.\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n /// Overview-only: forces the status-bar title into the compact %-text mode\n /// (\"Compactar\"); while compact it reads \"Expandir\" and turns it back off.\n let compactItem = NSMenuItem(title: \"Compactar\", action: nil, keyEquivalent: \"\")\n /// Which account each surface is signed in as. One dim line under the\n /// header, plus a submenu per surface. All three stay hidden until\n /// `account status --json` answers, so an older binary that doesn't know\n /// the subcommand simply shows the menu it always did.\n let accountsInfoItem = NSMenuItem()\n let desktopAccountSubmenu = NSMenu()\n let desktopAccountItem = NSMenuItem(title: \"Claude Desktop\", action: nil, keyEquivalent: \"\")\n let cliAccountSubmenu = NSMenu()\n let cliAccountItem = NSMenuItem(title: \"Claude Code\", action: nil, keyEquivalent: \"\")\n var lastAccountStatus: AccountStatus?\n var accountStatusFetchedAt = Date.distantPast\n var accountStatusGeneration = 0\n /// A switch runs a subprocess that quits and reopens another app; both\n /// submenus grey out until it returns so it cannot be fired twice.\n var accountSwitchInFlight = false\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true, \"compactShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleCompactHotKey),\n name: compactHotKeyNotification, object: nil)\n installSwapHotKey()\n installCompactHotKey()\n installConfigWatch()\n fetchAccountStatus()\n }\n\n /// Watch config.toml and hot-reload the vendor list when it changes on disk.\n /// Editors usually save atomically (write a temp file, then rename it over\n /// the target), which unlinks the inode our descriptor points at — so a\n /// `.delete`/`.rename` event means \"re-open the path to keep watching the\n /// new file\". A plain in-place write (`>>`, some editors) fires `.write` on\n /// the same descriptor. Idempotent: always tears down the previous watch.\n func installConfigWatch() {\n removeConfigWatch()\n let path = configPathTOML()\n let fd = open(path, O_EVTONLY)\n guard fd >= 0 else {\n // No readable file yet (not created, or momentarily gone between an\n // editor's unlink and rename). Retry shortly so a config created\n // after launch — or a non-atomic save's brief gap — is still picked\n // up. ponytail: a fixed 3s retry, not exponential backoff; this only\n // spins in the rare window before the file exists.\n DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in\n self?.installConfigWatch()\n }\n return\n }\n let src = DispatchSource.makeFileSystemObjectSource(\n fileDescriptor: fd, eventMask: [.write, .extend, .delete, .rename, .attrib],\n queue: .main)\n src.setEventHandler { [weak self] in\n guard let self, let flags = self.configWatchSource?.data else { return }\n self.scheduleConfigReload()\n // The watched inode was replaced; re-arm on whatever is at the path\n // now so future edits keep firing.\n if flags.contains(.delete) || flags.contains(.rename) {\n self.installConfigWatch()\n }\n }\n src.setCancelHandler { close(fd) }\n configWatchFD = fd\n configWatchSource = src\n src.resume()\n }\n\n func removeConfigWatch() {\n configWatchSource?.cancel() // the cancel handler closes the fd\n configWatchSource = nil\n configWatchFD = -1\n }\n\n /// Coalesce the burst of vnode events a save emits (write → rename → attrib)\n /// into one reload a beat later, mirroring `settingsChanged`'s debounce.\n func scheduleConfigReload() {\n pendingConfigReload?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.configFileChanged() }\n pendingConfigReload = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)\n }\n\n /// A config.toml edit landed: rebuild the vendor submenu/ring, re-render the\n /// current view from cache so an `[ui]` tweak shows at once, then debounce a\n /// full refresh to fetch any newly-added vendor/account. Reads are all fresh\n /// from disk (nothing caches config.toml), so no invalidation is needed.\n @objc func configFileChanged() {\n rebuildVendorSubmenu()\n // A new [[anthropic.accounts]] entry changes the Claude Code list.\n fetchAccountStatus()\n if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n guard removeSwapHotKey() else {\n // The old registration is still live; reflect that instead of\n // showing a disabled toggle that continues intercepting the key.\n DEF.set(true, forKey: \"swapShortcutEnabled\")\n return\n }\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n guard installHotKeyHandlerOnce() else {\n disableFailedShortcut(\"swapShortcutEnabled\", name: \"⌥⌘\\\\\")\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n return\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: SWAP_HOTKEY_ID) // 'AIBB'\n var ref: EventHotKeyRef?\n let status = RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &ref)\n guard hotKeyRegistrationSucceeded(status), let ref else {\n if let ref { UnregisterEventHotKey(ref) }\n disableFailedShortcut(\"swapShortcutEnabled\", name: \"⌥⌘\\\\\", status: status)\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n return\n }\n swapHotKeyRef = ref\n }\n\n /// (Re)register the global ⌥⌘E Compactar/Expandir hot key to match the\n /// `compactShortcutEnabled` preference. Idempotent, mirroring\n /// `installSwapHotKey` — including the painted-in hint (a native\n /// keyEquivalent would fire a second time while the menu is open, double-\n /// toggling on one press).\n func installCompactHotKey() {\n guard removeCompactHotKey() else {\n DEF.set(true, forKey: \"compactShortcutEnabled\")\n return\n }\n updateCompactItemTitle()\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else { return }\n guard installHotKeyHandlerOnce() else {\n disableFailedShortcut(\"compactShortcutEnabled\", name: \"⌥⌘E\")\n updateCompactItemTitle()\n return\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: COMPACT_HOTKEY_ID)\n var ref: EventHotKeyRef?\n let status = RegisterEventHotKey(\n COMPACT_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &ref)\n guard hotKeyRegistrationSucceeded(status), let ref else {\n if let ref { UnregisterEventHotKey(ref) }\n disableFailedShortcut(\"compactShortcutEnabled\", name: \"⌥⌘E\", status: status)\n updateCompactItemTitle()\n return\n }\n compactHotKeyRef = ref\n }\n\n /// Compactar ↔ Expandir label plus the dimmed ⌥⌘E hint when the global\n /// shortcut is on. Shared by the overview render and the hot-key installer.\n func updateCompactItemTitle() {\n let label = DEF.bool(forKey: \"overviewCompact\") ? \"Expandir\" : \"Compactar\"\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else {\n compactItem.attributedTitle = NSAttributedString(string: label)\n return\n }\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(string: \"\\(label) \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘E\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n compactItem.attributedTitle = s\n }\n\n /// One Carbon handler serves every hot key; the event's EventHotKeyID\n /// says which one fired.\n private func installHotKeyHandlerOnce() -> Bool {\n guard !swapHotKeyHandlerInstalled else { return true }\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n let status = InstallEventHandler(\n GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: global hot-key handler registration failed (OSStatus %d)\", status)\n return false\n }\n swapHotKeyHandlerInstalled = true\n return true\n }\n\n private func disableFailedShortcut(_ key: String, name: String,\n status: OSStatus? = nil) {\n if let status {\n NSLog(\"ai-usagebar: global shortcut %@ registration failed (OSStatus %d)\",\n name, status)\n } else {\n NSLog(\"ai-usagebar: global shortcut %@ registration failed\", name)\n }\n // Keep the visible preference honest when another app already owns the\n // shortcut or Carbon refuses the registration.\n DEF.set(false, forKey: key)\n }\n\n @discardableResult\n func removeSwapHotKey() -> Bool {\n if let ref = swapHotKeyRef {\n let status = UnregisterEventHotKey(ref)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: could not unregister ⌥⌘\\\\ (OSStatus %d)\", status)\n return false\n }\n swapHotKeyRef = nil\n }\n return true\n }\n\n @discardableResult\n private func removeCompactHotKey() -> Bool {\n if let ref = compactHotKeyRef {\n let status = UnregisterEventHotKey(ref)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: could not unregister ⌥⌘E (OSStatus %d)\", status)\n return false\n }\n compactHotKeyRef = nil\n }\n return true\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// ⌥⌘E: toggle Compactar/Expandir. Overview-only — flipping a hidden\n /// preference from another view would be invisible and confusing.\n @objc func handleCompactHotKey() {\n guard VENDOR == \"overview\" else { return }\n toggleCompact()\n }\n\n /// The swap-shortcut ring: every configured entry (vendors *and* Claude\n /// accounts) plus the synthetic \"overview\" target, so ⌥⌘\\ cycles them all.\n func swapCycleIds() -> [String] {\n var ids = vendorEntries(active: VENDOR).map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // Reads as a sub-line of the header, so it sits above the usage rows.\n accountsInfoItem.isEnabled = false\n accountsInfoItem.isHidden = true\n menu.addItem(accountsInfoItem)\n // One hidden slot per built-in vendor for Overview mode. Named accounts\n // can take the total higher; renderOverview grows the pool on demand.\n for _ in 0..<VENDOR_AUTH.count {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n // First item after the usage rows, Overview-only (hidden elsewhere).\n compactItem.action = #selector(toggleCompact)\n compactItem.target = self\n compactItem.isHidden = true\n menu.addItem(compactItem)\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n for (item, submenu) in [(desktopAccountItem, desktopAccountSubmenu),\n (cliAccountItem, cliAccountSubmenu)] {\n item.submenu = submenu\n item.isHidden = true\n menu.addItem(item)\n }\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n // Catches a switch made elsewhere (a terminal, claude-acc) without\n // polling for state that changes at most a few times a day.\n menu.delegate = self\n statusItem.menu = menu\n }\n\n func menuWillOpen(_ menu: NSMenu) {\n if Date().timeIntervalSince(accountStatusFetchedAt) >= 5 { fetchAccountStatus() }\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n /// Flip the overview's compact mode; the UserDefaults observer re-renders,\n /// which also relabels the item (Compactar ↔ Expandir).\n @objc func toggleCompact() {\n DEF.set(!DEF.bool(forKey: \"overviewCompact\"), forKey: \"overviewCompact\")\n }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n if overviewVisibilityChangePending {\n overviewVisibilityChangePending = false\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov) }\n return\n }\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n if DEF.bool(forKey: \"compactShortcutEnabled\") != (compactHotKeyRef != nil) {\n installCompactHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = entryDisplayName(VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: vendor) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n // vendorEntries(active: \"\") keeps only configured entries — the\n // active-vendor courtesy slot has no place in an all-vendors sweep.\n let entries = filterOverviewEntries(vendorEntries(active: \"\"),\n requested: configuredOverviewVendorIds())\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in entries {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: v.id) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: baseVendorId(v.id))\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// Compact bar label for a vendor name. Usually the first word; for a\n /// Claude account (\"Claude · gmail\") the account label is the part that\n /// distinguishes entries, so use it instead of the shared \"Claude\".\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let base = name.range(of: \" · \").map { String(name[$0.upperBound...]) }\n ?? String(name.split(separator: \" \").first ?? Substring(name))\n return base.count <= maxLen ? base : String(base.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 4) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n // Providers toggled off in the dropdown are dropped from the always-on\n // top summary (but still listed there so they can be re-enabled).\n let hidden = overviewHiddenProviders()\n let visible = Set(overviewVisibleIds(items.map { $0.id }, hidden: hidden))\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String, reset: String?)] =\n items.compactMap {\n guard visible.contains($0.id), let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\", nil) }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\", h.reset.flatMap(shortReset))\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 4\n let compact = DEF.bool(forKey: \"overviewCompact\")\n let t = NSMutableAttributedString()\n if overviewUsesBars(count: heads.count, barsMax: barsMax, compact: compact) {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n // Entry order, not worst-first: entries are provider-grouped\n // (standard Claude, then its accounts, then the other vendors), and\n // stable positions beat a pct sort that reshuffles labels on every\n // refresh.\n for (i, e) in heads.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n }\n if heads.count > maxN { t.append(run(\" +\\(heads.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n ensureOverviewRowCapacity(items.count)\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n let hidden = overviewHiddenProviders()\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to toggle whether this provider shows in the top-bar\n // summary. Checkmark = shown; unchecked + dimmed = hidden from the\n // bar (still listed here so it can be turned back on). Jump-to-vendor\n // moved to the \"Trocar vendor\" submenu and ⌥⌘\\.\n let isHidden = hidden.contains(item.id)\n it.state = isHidden ? .off : .on\n it.representedObject = item.id\n it.target = self\n it.action = #selector(toggleOverviewProvider(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n // Grey the whole row while hidden, so \"off\" reads at a glance.\n if isHidden {\n a.addAttribute(.foregroundColor, value: NSColor.tertiaryLabelColor,\n range: NSRange(location: 0, length: a.length))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n compactItem.isHidden = false\n updateCompactItemTitle()\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n /// Add overview row slots before the single-vendor rows when newly\n /// discovered Claude accounts outgrow the initial built-in-vendor pool.\n private func ensureOverviewRowCapacity(_ count: Int) {\n guard count > overviewRows.count,\n let menu = statusItem.menu,\n let firstDetail = rows[\"session\"] else { return }\n while overviewRows.count < count {\n let item = NSMenuItem()\n item.isHidden = true\n menu.insertItem(item, at: menu.index(of: firstDetail))\n overviewRows.append(item)\n }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: baseVendorId(VENDOR)) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n // With several Claude accounts the plan alone (\"Claude Max 20x\") no\n // longer says WHICH account this is — suffix the label.\n let plan = s.plan.isEmpty ? \"AI Usage\" : s.plan\n let header = accountLabel(of: VENDOR).map { \"\\(plan) · \\($0)\" } ?? plan\n headerItem.attributedTitle = run(header, .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let entries = vendorEntries(active: active)\n if entries.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in entries {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n /// Ask the binary who each surface is signed in as. Same off-main shape as\n /// `refresh()`; failures leave `lastAccountStatus` alone so a transient\n /// hiccup doesn't blank a working menu.\n func fetchAccountStatus() {\n guard let bin = resolveBinary(\"ai-usagebar\") else { return }\n accountStatusFetchedAt = Date()\n accountStatusGeneration += 1\n let generation = accountStatusGeneration\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = [\"account\", \"status\", \"--json\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var data = Data()\n do {\n try p.run()\n data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n } catch {\n watchdog.cancel()\n return\n }\n watchdog.cancel()\n guard let status = parseAccountStatus(data) else { return }\n DispatchQueue.main.async {\n guard let me = self, generation == me.accountStatusGeneration else { return }\n me.applyAccountStatus(status)\n }\n }\n }\n\n func applyAccountStatus(_ status: AccountStatus) {\n lastAccountStatus = status\n renderAccountMenus()\n }\n\n func renderAccountMenus() {\n // Nil means the binary predates the subcommand: leave the menu exactly\n // as it was before this feature existed.\n guard let status = lastAccountStatus else {\n accountsInfoItem.isHidden = true\n desktopAccountItem.isHidden = true\n cliAccountItem.isHidden = true\n return\n }\n let line = accountsSummaryLine(status)\n accountsInfoItem.isHidden = line.isEmpty\n accountsInfoItem.attributedTitle = run(line, .secondaryLabelColor)\n\n fill(desktopAccountSubmenu, status.desktopLabels, status.desktopActive,\n #selector(switchDesktopAccount(_:)))\n fill(cliAccountSubmenu, status.cliLabels, status.cliActive,\n #selector(switchCliAccount(_:)))\n // Both stay visible with an empty list — that is when \"Adicionar\n // conta…\" matters most. Only a machine with no Claude Desktop app at\n // all loses its submenu.\n desktopAccountItem.isHidden = !status.desktopAvailable\n cliAccountItem.isHidden = false\n desktopAccountItem.isEnabled = !accountSwitchInFlight\n cliAccountItem.isEnabled = !accountSwitchInFlight\n }\n\n private func fill(_ submenu: NSMenu, _ labels: [String], _ active: String?, _ action: Selector) {\n submenu.removeAllItems()\n for label in labels {\n let it = NSMenuItem(title: label, action: action, keyEquivalent: \"\")\n it.target = self\n it.representedObject = label\n it.state = (label == active) ? .on : .off\n it.isEnabled = !accountSwitchInFlight && label != active\n submenu.addItem(it)\n }\n submenu.addItem(.separator())\n let add = NSMenuItem(title: \"Adicionar conta…\",\n action: #selector(addAccount(_:)), keyEquivalent: \"\")\n add.target = self\n add.representedObject = (submenu === desktopAccountSubmenu)\n add.isEnabled = !accountSwitchInFlight\n submenu.addItem(add)\n }\n\n /// Ask for a label, then hand the interactive part to Terminal: a Desktop\n /// capture waits for a browser sign-in, a CLI one runs `claude`. Neither\n /// belongs in a background subprocess the user cannot see or answer.\n @objc func addAccount(_ sender: NSMenuItem) {\n guard let desktop = sender.representedObject as? Bool,\n let bin = resolveBinary(\"ai-usagebar\") else { return }\n let alert = NSAlert()\n alert.messageText = desktop ? \"Adicionar conta do Claude Desktop\"\n : \"Adicionar conta do Claude Code\"\n alert.informativeText = desktop\n ? \"Escolha um nome. O app será fechado e reaberto na tela de login para você \"\n + \"entrar com a conta nova; a atual é restaurada se você cancelar.\"\n : \"Escolha um nome. O `claude` abrirá no Terminal para você entrar; seu login \"\n + \"padrão não é alterado.\"\n let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 220, height: 24))\n field.placeholderString = \"trabalho\"\n alert.accessoryView = field\n alert.addButton(withTitle: \"Continuar\")\n alert.addButton(withTitle: \"Cancelar\")\n NSApp.activate(ignoringOtherApps: true)\n alert.window.initialFirstResponder = field\n guard alert.runModal() == .alertFirstButtonReturn else { return }\n let label = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)\n guard !label.isEmpty else { return }\n runInTerminal(addAccountScript(binary: bin, label: label, desktop: desktop))\n }\n\n /// Quits and reopens Claude.app, so confirm first — an unsent message or an\n /// in-flight Cowork run would go with it. The CLI switch has no visible\n /// side effect and needs no prompt.\n @objc func switchDesktopAccount(_ sender: NSMenuItem) {\n guard let label = sender.representedObject as? String else { return }\n let alert = NSAlert()\n alert.messageText = \"Trocar a conta do Claude Desktop para «\\(label)»?\"\n alert.informativeText = \"O app será fechado e reaberto. Seu histórico local é \"\n + \"mesclado nessa conta antes da troca, e uma cópia de segurança é gravada.\"\n alert.addButton(withTitle: \"Trocar e reiniciar\")\n alert.addButton(withTitle: \"Cancelar\")\n NSApp.activate(ignoringOtherApps: true)\n guard alert.runModal() == .alertFirstButtonReturn else { return }\n runAccountSwitch(label: label, desktop: true)\n }\n\n @objc func switchCliAccount(_ sender: NSMenuItem) {\n guard let label = sender.representedObject as? String else { return }\n runAccountSwitch(label: label, desktop: false)\n }\n\n private func runAccountSwitch(label: String, desktop: Bool) {\n guard let bin = resolveBinary(\"ai-usagebar\"), !accountSwitchInFlight else { return }\n accountSwitchInFlight = true\n renderAccountMenus()\n let args = switchArgs(label: label, desktop: desktop)\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = args\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = pipe\n let failure: String?\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n if p.terminationStatus != 0 {\n let detail = String(decoding: data, as: UTF8.self)\n .trimmingCharacters(in: .whitespacesAndNewlines)\n failure = detail.isEmpty\n ? \"ai-usagebar terminou com status \\(p.terminationStatus).\"\n : String(detail.prefix(2_000))\n } else {\n failure = nil\n }\n } catch {\n failure = \"Não foi possível iniciar ai-usagebar: \\(error.localizedDescription)\"\n }\n DispatchQueue.main.async {\n guard let me = self else { return }\n me.accountSwitchInFlight = false\n me.fetchAccountStatus()\n if let failure {\n let alert = NSAlert()\n alert.alertStyle = .warning\n alert.messageText = \"Não foi possível trocar a conta\"\n alert.informativeText = failure\n alert.addButton(withTitle: \"OK\")\n NSApp.activate(ignoringOtherApps: true)\n alert.runModal()\n } else {\n // The usage numbers belong to whoever is signed in now.\n me.refresh()\n }\n }\n }\n }\n\n /// Toggle whether a provider appears in the Overview top-bar summary, from\n /// its dropdown row. The UserDefaults observer re-renders from the last\n /// fetch and consumes this presentation-only change without re-fetching.\n @objc func toggleOverviewProvider(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n overviewVisibilityChangePending = true\n setOverviewProvider(id, hidden: !overviewHiddenProviders().contains(id))\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n accountsInfoItem.isHidden = true\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "462fe55d97bb0c59caef75504d191ba14abc1736": {"t": 1785750116.4855223, "n": 118896, "h": "d5d1cd56fc33d431", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\n/// A Desktop account switch merges history, writes an archive, and quits and\n/// reopens Claude.app — minutes of work on a large history, not seconds.\nlet ACCOUNT_SWITCH_TIMEOUT: Double = 300\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\n/// Squeeze a countdown (\"4d 1h\", \"2h 05m\", \"now\") down to its leading unit for\n/// the overview status-bar title, where every character fights for space:\n/// \"4d 1h\" → \"4d\", \"2h 05m\" → \"2h\", \"0h 05m\" → \"5m\". Missing/em-dash → nil.\nfunc shortReset(_ r: String) -> String? {\n guard !r.isEmpty, r != \"—\" else { return nil }\n let parts = r.split(separator: \" \")\n guard let first = parts.first else { return nil }\n if first == \"0h\", parts.count > 1 {\n let m = parts[1].drop(while: { $0 == \"0\" })\n return m == \"m\" ? \"0m\" : String(m)\n }\n return String(first)\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\n/// Read a TOML array of quoted strings. This intentionally stays small (the\n/// native app is a single dependency-free Swift file), but accepts multiline\n/// arrays and rejects malformed values instead of silently widening Overview.\nfunc tomlStringArrayInText(_ text: String, section: String, key: String) -> [String]? {\n func withoutComment(_ line: String) -> String {\n var result = \"\"\n var quote: Character?\n var escaped = false\n for ch in line {\n if let active = quote {\n result.append(ch)\n if active == \"\\\"\", escaped {\n escaped = false\n } else if active == \"\\\"\", ch == \"\\\\\" {\n escaped = true\n } else if ch == active {\n quote = nil\n }\n } else if ch == \"#\" {\n break\n } else {\n result.append(ch)\n if ch == \"\\\"\" || ch == \"'\" { quote = ch }\n }\n }\n return result\n }\n\n func hasClosingBracket(_ value: String) -> Bool {\n var quote: Character?\n var escaped = false\n for ch in value {\n if let active = quote {\n if active == \"\\\"\", escaped {\n escaped = false\n } else if active == \"\\\"\", ch == \"\\\\\" {\n escaped = true\n } else if ch == active {\n quote = nil\n }\n } else if ch == \"\\\"\" || ch == \"'\" {\n quote = ch\n } else if ch == \"]\" {\n return true\n }\n }\n return false\n }\n\n var inSection = false\n var rawValue: String?\n for rawLine in text.components(separatedBy: .newlines) {\n let line = withoutComment(rawLine).trimmingCharacters(in: .whitespaces)\n if let current = rawValue {\n let joined = current + \" \" + line\n rawValue = joined\n if hasClosingBracket(joined) { break }\n continue\n }\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.isEmpty else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n guard value.hasPrefix(\"[\") else { return nil }\n rawValue = value\n if hasClosingBracket(value) { break }\n }\n\n guard let raw = rawValue, hasClosingBracket(raw) else { return nil }\n let chars = Array(raw)\n guard chars.first == \"[\" else { return nil }\n var result: [String] = []\n var i = 1\n\n func skipSpace() {\n while i < chars.count && chars[i].isWhitespace { i += 1 }\n }\n\n while true {\n skipSpace()\n guard i < chars.count else { return nil }\n if chars[i] == \"]\" {\n i += 1\n skipSpace()\n return i == chars.count ? result : nil\n }\n let quote = chars[i]\n guard quote == \"\\\"\" || quote == \"'\" else { return nil }\n i += 1\n var value = \"\"\n var closed = false\n while i < chars.count {\n let ch = chars[i]\n i += 1\n if ch == quote {\n closed = true\n break\n }\n if quote == \"\\\"\", ch == \"\\\\\", i < chars.count {\n value.append(chars[i])\n i += 1\n } else {\n value.append(ch)\n }\n }\n guard closed else { return nil }\n result.append(value)\n skipSpace()\n guard i < chars.count else { return nil }\n if chars[i] == \",\" {\n i += 1\n continue\n }\n guard chars[i] == \"]\" else { return nil }\n }\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc configuredOverviewVendorIds() -> [String]? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlStringArrayInText(text, section: \"ui\", key: \"overview_vendors\")\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n// ── Anthropic multi-account ────────────────────────────────────────────────\n// Mirrors the Rust side (`src/config.rs`): explicit `[[anthropic.accounts]]`\n// entries plus subdirectories of `[anthropic] accounts_dir`, explicit labels\n// winning on a clash. Each account is a\n// selectable entry with the pseudo-id `anthropic@<label>`, fetched by the\n// binary as `--vendor anthropic --account <label>` — the binary resolves the\n// account's credentials itself (file or its own Keychain item), so the app\n// never needs to know where they live.\n\n/// `[[anthropic.accounts]]` labels, in file order. Pure text scan, like the\n/// rest of this app's TOML reading: a `[[anthropic.accounts]]` header opens a\n/// block, the first `label = \"…\"` inside it counts.\nfunc anthropicAccountLabels(inTOML text: String) -> [String] {\n var labels: [String] = []\n var inBlock = false\n for rawLine in text.components(separatedBy: .newlines) {\n let line = rawLine.trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inBlock = line == \"[[anthropic.accounts]]\"\n continue\n }\n guard inBlock, line.hasPrefix(\"label\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == \"label\" else { continue }\n var value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n value = String(content[..<end])\n }\n if !value.isEmpty {\n labels.append(value)\n inBlock = false // one label per block\n }\n }\n return labels\n}\n\n/// Immediate subdirectories of `dir`, sorted — the same discovery rule as the\n/// Rust `accounts_dir` scan. Credentials may be file- or Keychain-backed.\nfunc discoverAccountLabels(inDir dir: String) -> [String] {\n let fm = FileManager.default\n guard let names = try? fm.contentsOfDirectory(atPath: dir) else { return [] }\n return names.filter { name in\n var isDir: ObjCBool = false\n let sub = \"\\(dir)/\\(name)\"\n return fm.fileExists(atPath: sub, isDirectory: &isDir) && isDir.boolValue\n }.sorted()\n}\n\n/// Explicit labels first (file order), then discovered ones that don't clash.\nfunc mergedAccountLabels(explicit: [String], discovered: [String]) -> [String] {\n explicit + discovered.filter { !explicit.contains($0) }\n}\n\n/// All configured Claude account labels, from the same config the binary reads.\nfunc claudeAccountLabels() -> [String] {\n guard let text = try? String(contentsOfFile: configPathTOML(), encoding: .utf8) else {\n return []\n }\n let explicit = anthropicAccountLabels(inTOML: text)\n var discovered: [String] = []\n if let dir = tomlValueInText(text, section: \"anthropic\", key: \"accounts_dir\"), !dir.isEmpty {\n discovered = discoverAccountLabels(inDir: NSString(string: dir).expandingTildeInPath)\n }\n return mergedAccountLabels(explicit: explicit, discovered: discovered)\n}\n\n/// Rust semantics (`show_default_account`): `false` hides the default\n/// (unnamed) Claude entry, but is ignored when there are no named accounts,\n/// so Anthropic never loses its only entry.\nfunc showDefaultClaudeAccount(configValue: String?, hasAccounts: Bool) -> Bool {\n guard hasAccounts else { return true }\n return configValue != \"false\"\n}\n\n/// Pseudo-id mapping: `anthropic@<label>` selects a named account.\nlet ACCOUNT_ID_PREFIX = \"anthropic@\"\n\nfunc accountLabel(of id: String) -> String? {\n id.hasPrefix(ACCOUNT_ID_PREFIX) ? String(id.dropFirst(ACCOUNT_ID_PREFIX.count)) : nil\n}\n\nfunc baseVendorId(_ id: String) -> String {\n accountLabel(of: id) != nil ? \"anthropic\" : id\n}\n\n/// The subprocess arguments that select `id` (base vendor or named account).\nfunc vendorArgs(for id: String) -> [String] {\n if let label = accountLabel(of: id) {\n return [\"--vendor\", \"anthropic\", \"--account\", label]\n }\n return [\"--vendor\", id]\n}\n\n/// One selectable entry: a base vendor or one Claude account.\nstruct MenuEntry {\n let id: String // \"cursor\", \"anthropic\", or \"anthropic@<label>\"\n let name: String // display: \"Cursor\", \"Claude · <label>\"\n}\n\n/// Apply `[ui] overview_vendors` with the same semantics as the TUI: preserve\n/// config order, omit unavailable vendors, and include every named Claude\n/// account when `anthropic` is requested.\nfunc filterOverviewEntries(_ entries: [MenuEntry], requested: [String]?) -> [MenuEntry] {\n guard let requested else { return entries }\n var result: [MenuEntry] = []\n var seen = Set<String>()\n for vendor in requested {\n for entry in entries where baseVendorId(entry.id) == vendor && !seen.contains(entry.id) {\n result.append(entry)\n seen.insert(entry.id)\n }\n }\n return result\n}\n\n/// The selectable entries, in menu order: every enabled+configured vendor,\n/// with Anthropic expanded into its named accounts (the default entry kept\n/// only per `show_default_account`). `active` stays listed even when\n/// unconfigured — same rule the per-vendor list always had.\nfunc vendorEntries(active: String) -> [MenuEntry] {\n var out: [MenuEntry] = []\n for v in VENDOR_AUTH where vendorEnabled(v) {\n if v.id == \"anthropic\" {\n let labels = claudeAccountLabels()\n let showDefault = showDefaultClaudeAccount(\n configValue: configValueTOML(\"anthropic\", \"show_default_account\"),\n hasAccounts: !labels.isEmpty)\n if showDefault && (v.id == active || vendorConfigured(v)) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n for label in labels {\n out.append(MenuEntry(id: ACCOUNT_ID_PREFIX + label, name: \"Claude · \\(label)\"))\n }\n } else if v.id == active || vendorConfigured(v) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n }\n return out\n}\n\n/// Display name for any selectable id (base vendor, account, or overview).\nfunc entryDisplayName(_ id: String) -> String {\n if id == \"overview\" { return \"Visão geral\" }\n if let label = accountLabel(of: id) { return \"Claude · \\(label)\" }\n return VENDOR_AUTH.first { $0.id == id }?.name ?? id\n}\n\n// MARK: - Which account each surface is signed in as\n//\n// Separate from the vendor entries above: those decide whose usage is *shown*,\n// these are who the Claude Desktop app and the `claude` CLI are actually signed\n// in as. Both come from `ai-usagebar account status --json`; the binary owns\n// every path and credential detail, so this side only parses and displays.\n\nstruct AccountStatus: Equatable {\n /// False when there is no Claude Desktop app on this machine. Distinct from\n /// \"no accounts saved yet\": with the app present but nothing captured, the\n /// submenu still has to appear so the user can add the first one.\n var desktopAvailable = false\n var desktopActive: String?\n var cliActive: String?\n var desktopLabels: [String] = []\n var cliLabels: [String] = []\n}\n\n/// Parse `account status --json`. Every field is optional on purpose: a Mac\n/// with no Claude Desktop app reports `\"desktop\": null`, and an older binary\n/// may not know the subcommand at all — both must degrade to \"no submenus\"\n/// rather than crash.\nfunc parseAccountStatus(_ data: Data) -> AccountStatus? {\n guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {\n return nil\n }\n func labels(_ side: Any?, _ key: String) -> [String] {\n guard let side = side as? [String: Any], let rows = side[key] as? [[String: Any]] else {\n return []\n }\n return rows.compactMap { $0[\"label\"] as? String }\n }\n func active(_ side: Any?) -> String? {\n (side as? [String: Any])?[\"active_label\"] as? String\n }\n let desktop = root[\"desktop\"], cli = root[\"cli\"]\n return AccountStatus(desktopAvailable: desktop is [String: Any],\n desktopActive: active(desktop),\n cliActive: active(cli),\n desktopLabels: labels(desktop, \"profiles\"),\n cliLabels: labels(cli, \"accounts\"))\n}\n\n/// The one dim line under the header. Empty when neither surface resolved, so\n/// the caller can hide the row entirely rather than show a bare label.\nfunc accountsSummaryLine(_ s: AccountStatus) -> String {\n var parts: [String] = []\n if !s.desktopLabels.isEmpty { parts.append(\"Desktop: \\(s.desktopActive ?? \"?\")\") }\n if !s.cliLabels.isEmpty { parts.append(\"Code: \\(s.cliActive ?? \"?\")\") }\n return parts.joined(separator: \" · \")\n}\n\n/// `-y` because the menu has already asked; without it the binary would prompt\n/// on a stdin that is not a terminal and abort.\nfunc switchArgs(label: String, desktop: Bool) -> [String] {\n [\"account\", \"switch\", label, desktop ? \"--desktop\" : \"--cli\", \"-y\"]\n}\n\n/// Adding an account is interactive — a Desktop capture waits for you to sign\n/// in, and a CLI one runs `claude` — so it goes to Terminal rather than being\n/// swallowed by a background subprocess. Single-quoted with `'` doubled out,\n/// so a label with a quote in it can't break out of the command.\nfunc addAccountScript(binary: String, label: String, desktop: Bool) -> String {\n func quote(_ s: String) -> String { \"'\" + s.replacingOccurrences(of: \"'\", with: \"'\\\\''\") + \"'\" }\n return \"#!/usr/bin/env bash\\n\"\n + \"\\(quote(binary)) account add \\(quote(label))\\(desktop ? \" --desktop\" : \"\")\\n\"\n + \"echo; read -n 1 -s -r -p 'Pressione qualquer tecla para fechar…'\\n\"\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"compactShortcutEnabled\") private var compactShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n @State private var launchAtLogin = launchAgentIsInstalled()\n @State private var launchAtLoginError: String?\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker. Claude\n // accounts appear as their `anthropic@<label>` pseudo-ids, same as the\n // \"Trocar vendor\" submenu.\n private var vendors: [String] {\n var ids = VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n let labels = claudeAccountLabels()\n if let at = ids.firstIndex(of: \"anthropic\") {\n ids.insert(contentsOf: labels.map { ACCOUNT_ID_PREFIX + $0 }, at: at + 1)\n }\n return ids\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n Toggle(\"Compactar/Expandir com ⌥⌘E (atalho global)\", isOn: $compactShortcutEnabled)\n Text(\"Alterna a Visão geral entre barras e modo compacto.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Sistema\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Iniciar no login\", isOn: Binding(\n get: { launchAtLogin },\n set: { enabled in\n do {\n try setLaunchAtLogin(enabled)\n launchAtLogin = launchAgentIsInstalled()\n launchAtLoginError = nil\n } catch {\n launchAtLogin = launchAgentIsInstalled()\n launchAtLoginError = error.localizedDescription\n }\n }))\n Text(\"Instala um LaunchAgent que sobe o app ao entrar na sua conta.\")\n .font(.caption).foregroundColor(.secondary)\n if let error = launchAtLoginError {\n Text(\"Não foi possível atualizar: \\(error)\")\n .font(.caption).foregroundColor(.red)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n .onAppear { launchAtLogin = launchAgentIsInstalled() }\n }\n}\n\n// ─── Launch at login (macOS LaunchAgent) ─────────────────────────────────\n//\n// The app is an unbundled single binary, so the modern SMAppService.mainApp\n// API (which needs a bundle id) doesn't apply. A per-user LaunchAgent is the\n// portable way to start an unbundled executable at login — the same mechanism\n// install-agent.sh sets up, now toggleable from Preferences.\nlet LAUNCH_AGENT_LABEL = \"com.akitaonrails.ai-usagebar-menubar\"\n\nfunc launchAgentPlistPath() -> String {\n \"\\(NSHomeDirectory())/Library/LaunchAgents/\\(LAUNCH_AGENT_LABEL).plist\"\n}\n\nfunc launchAgentIsInstalled() -> Bool {\n FileManager.default.fileExists(atPath: launchAgentPlistPath())\n}\n\n/// Absolute path of the running executable, for the LaunchAgent to relaunch.\nfunc selfExecutablePath() -> String {\n if let p = Bundle.main.executablePath, !p.isEmpty { return p }\n let arg0 = CommandLine.arguments.first ?? \"ai-usagebar-menubar\"\n if arg0.hasPrefix(\"/\") { return arg0 }\n return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)\n .appendingPathComponent(arg0).standardized.path\n}\n\n/// Install (or remove) the login LaunchAgent by managing its plist file.\n/// Building the plist via PropertyListSerialization sidesteps the XML-escaping\n/// the shell installer has to do by hand.\n///\n/// Deliberately no `launchctl load`/`unload`: the app is already running when\n/// this is toggled, so `RunAtLoad` on load would spawn a *second* copy, and\n/// `unload` on disable would SIGTERM this very process (quitting the app the\n/// user is still using) if it happened to be the launchd-managed one. launchd\n/// loads every agent in ~/Library/LaunchAgents at the next login, so writing\n/// (or removing) the file is all that's needed for a start-at-login toggle.\nfunc launchAgentPlist(executable: String) throws -> Data {\n let plist: [String: Any] = [\n \"Label\": LAUNCH_AGENT_LABEL,\n \"ProgramArguments\": [executable],\n \"RunAtLoad\": true,\n \"ProcessType\": \"Interactive\",\n ]\n return try PropertyListSerialization.data(\n fromPropertyList: plist, format: .xml, options: 0)\n}\n\nfunc setLaunchAtLogin(_ enabled: Bool) throws {\n let path = launchAgentPlistPath()\n if enabled {\n let dir = (path as NSString).deletingLastPathComponent\n try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)\n let data = try launchAgentPlist(executable: selfExecutablePath())\n try data.write(to: URL(fileURLWithPath: path), options: .atomic)\n } else if FileManager.default.fileExists(atPath: path) {\n try FileManager.default.removeItem(atPath: path)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\nlet compactHotKeyNotification = Notification.Name(\"aiusagebar.compactHotKey\")\n\n/// Default shortcuts: `\\` (kVK_ANSI_Backslash) with Command+Option swaps the\n/// vendor; `E` with Command+Option toggles the overview's Compactar/Expandir.\n/// `[ui]` on Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet COMPACT_HOTKEY_KEYCODE = UInt32(kVK_ANSI_E)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\n/// Registered hot-key ids, carried back to us in the event so one shared C\n/// callback can tell which shortcut fired.\nlet SWAP_HOTKEY_ID = UInt32(1)\nlet COMPACT_HOTKEY_ID = UInt32(2)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n var hk = EventHotKeyID()\n GetEventParameter(event, EventParamName(kEventParamDirectObject),\n EventParamType(typeEventHotKeyID), nil,\n MemoryLayout<EventHotKeyID>.size, nil, &hk)\n NotificationCenter.default.post(\n name: hk.id == COMPACT_HOTKEY_ID ? compactHotKeyNotification : swapHotKeyNotification,\n object: nil)\n return noErr\n}\n\n/// Whether the overview status-bar title draws mini bars (vs. the compact\n/// %-text mode). \"Compactar\" forces the text mode even under the bars-count\n/// threshold. Pure + testable — the render path is not.\nfunc overviewUsesBars(count: Int, barsMax: Int, compact: Bool) -> Bool {\n !compact && count <= barsMax\n}\n\n/// Provider ids the user toggled out of the always-visible top-bar summary in\n/// Overview mode. The dropdown still lists them (unchecked + dimmed) so they can\n/// be turned back on. Persisted in UserDefaults; absent key → nothing hidden.\nfunc overviewHiddenProviders() -> Set<String> {\n Set(DEF.stringArray(forKey: \"overviewHiddenProviders\") ?? [])\n}\n\nfunc setOverviewProvider(_ id: String, hidden: Bool) {\n var hiddenSet = overviewHiddenProviders()\n if hidden { hiddenSet.insert(id) } else { hiddenSet.remove(id) }\n DEF.set(hiddenSet.sorted(), forKey: \"overviewHiddenProviders\")\n}\n\n/// The ids that survive the top-bar filter, in their original order. Pure +\n/// testable — the render path (which also drops nil snapshots) is not.\nfunc overviewVisibleIds(_ ids: [String], hidden: Set<String>) -> [String] {\n ids.filter { !hidden.contains($0) }\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nfunc hotKeyRegistrationSucceeded(_ status: OSStatus) -> Bool {\n status == noErr\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var compactHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Live watch on config.toml so external edits (a text editor, the TUI's\n /// Settings overlay, `ai-usagebar account add`) hot-reload\n /// the vendor list without restarting the app. The fd is closed by the\n /// source's cancel handler; `pendingConfigReload` coalesces an editor's\n /// burst of vnode events into a single reload.\n var configWatchSource: DispatchSourceFileSystemObject?\n var configWatchFD: CInt = -1\n var pendingConfigReload: DispatchWorkItem?\n /// A visibility checkbox changes presentation only. Its UserDefaults\n /// notification should repaint from cache without restarting the timer or\n /// launching a fresh round of provider subprocesses.\n var overviewVisibilityChangePending = false\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor/account); hidden in\n // single-vendor mode. The pool starts at the built-in vendor count and can\n // grow if account discovery adds more rows.\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n /// Overview-only: forces the status-bar title into the compact %-text mode\n /// (\"Compactar\"); while compact it reads \"Expandir\" and turns it back off.\n let compactItem = NSMenuItem(title: \"Compactar\", action: nil, keyEquivalent: \"\")\n /// Which account each surface is signed in as. One dim line under the\n /// header, plus a submenu per surface. All three stay hidden until\n /// `account status --json` answers, so an older binary that doesn't know\n /// the subcommand simply shows the menu it always did.\n let accountsInfoItem = NSMenuItem()\n let desktopAccountSubmenu = NSMenu()\n let desktopAccountItem = NSMenuItem(title: \"Claude Desktop\", action: nil, keyEquivalent: \"\")\n let cliAccountSubmenu = NSMenu()\n let cliAccountItem = NSMenuItem(title: \"Claude Code\", action: nil, keyEquivalent: \"\")\n var lastAccountStatus: AccountStatus?\n var accountStatusFetchedAt = Date.distantPast\n /// A switch runs a subprocess that quits and reopens another app; both\n /// submenus grey out until it returns so it cannot be fired twice.\n var accountSwitchInFlight = false\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true, \"compactShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleCompactHotKey),\n name: compactHotKeyNotification, object: nil)\n installSwapHotKey()\n installCompactHotKey()\n installConfigWatch()\n fetchAccountStatus()\n }\n\n /// Watch config.toml and hot-reload the vendor list when it changes on disk.\n /// Editors usually save atomically (write a temp file, then rename it over\n /// the target), which unlinks the inode our descriptor points at — so a\n /// `.delete`/`.rename` event means \"re-open the path to keep watching the\n /// new file\". A plain in-place write (`>>`, some editors) fires `.write` on\n /// the same descriptor. Idempotent: always tears down the previous watch.\n func installConfigWatch() {\n removeConfigWatch()\n let path = configPathTOML()\n let fd = open(path, O_EVTONLY)\n guard fd >= 0 else {\n // No readable file yet (not created, or momentarily gone between an\n // editor's unlink and rename). Retry shortly so a config created\n // after launch — or a non-atomic save's brief gap — is still picked\n // up. ponytail: a fixed 3s retry, not exponential backoff; this only\n // spins in the rare window before the file exists.\n DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in\n self?.installConfigWatch()\n }\n return\n }\n let src = DispatchSource.makeFileSystemObjectSource(\n fileDescriptor: fd, eventMask: [.write, .extend, .delete, .rename, .attrib],\n queue: .main)\n src.setEventHandler { [weak self] in\n guard let self, let flags = self.configWatchSource?.data else { return }\n self.scheduleConfigReload()\n // The watched inode was replaced; re-arm on whatever is at the path\n // now so future edits keep firing.\n if flags.contains(.delete) || flags.contains(.rename) {\n self.installConfigWatch()\n }\n }\n src.setCancelHandler { close(fd) }\n configWatchFD = fd\n configWatchSource = src\n src.resume()\n }\n\n func removeConfigWatch() {\n configWatchSource?.cancel() // the cancel handler closes the fd\n configWatchSource = nil\n configWatchFD = -1\n }\n\n /// Coalesce the burst of vnode events a save emits (write → rename → attrib)\n /// into one reload a beat later, mirroring `settingsChanged`'s debounce.\n func scheduleConfigReload() {\n pendingConfigReload?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.configFileChanged() }\n pendingConfigReload = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)\n }\n\n /// A config.toml edit landed: rebuild the vendor submenu/ring, re-render the\n /// current view from cache so an `[ui]` tweak shows at once, then debounce a\n /// full refresh to fetch any newly-added vendor/account. Reads are all fresh\n /// from disk (nothing caches config.toml), so no invalidation is needed.\n @objc func configFileChanged() {\n rebuildVendorSubmenu()\n // A new [[anthropic.accounts]] entry changes the Claude Code list.\n fetchAccountStatus()\n if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n guard removeSwapHotKey() else {\n // The old registration is still live; reflect that instead of\n // showing a disabled toggle that continues intercepting the key.\n DEF.set(true, forKey: \"swapShortcutEnabled\")\n return\n }\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n guard installHotKeyHandlerOnce() else {\n disableFailedShortcut(\"swapShortcutEnabled\", name: \"⌥⌘\\\\\")\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n return\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: SWAP_HOTKEY_ID) // 'AIBB'\n var ref: EventHotKeyRef?\n let status = RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &ref)\n guard hotKeyRegistrationSucceeded(status), let ref else {\n if let ref { UnregisterEventHotKey(ref) }\n disableFailedShortcut(\"swapShortcutEnabled\", name: \"⌥⌘\\\\\", status: status)\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n return\n }\n swapHotKeyRef = ref\n }\n\n /// (Re)register the global ⌥⌘E Compactar/Expandir hot key to match the\n /// `compactShortcutEnabled` preference. Idempotent, mirroring\n /// `installSwapHotKey` — including the painted-in hint (a native\n /// keyEquivalent would fire a second time while the menu is open, double-\n /// toggling on one press).\n func installCompactHotKey() {\n guard removeCompactHotKey() else {\n DEF.set(true, forKey: \"compactShortcutEnabled\")\n return\n }\n updateCompactItemTitle()\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else { return }\n guard installHotKeyHandlerOnce() else {\n disableFailedShortcut(\"compactShortcutEnabled\", name: \"⌥⌘E\")\n updateCompactItemTitle()\n return\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: COMPACT_HOTKEY_ID)\n var ref: EventHotKeyRef?\n let status = RegisterEventHotKey(\n COMPACT_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &ref)\n guard hotKeyRegistrationSucceeded(status), let ref else {\n if let ref { UnregisterEventHotKey(ref) }\n disableFailedShortcut(\"compactShortcutEnabled\", name: \"⌥⌘E\", status: status)\n updateCompactItemTitle()\n return\n }\n compactHotKeyRef = ref\n }\n\n /// Compactar ↔ Expandir label plus the dimmed ⌥⌘E hint when the global\n /// shortcut is on. Shared by the overview render and the hot-key installer.\n func updateCompactItemTitle() {\n let label = DEF.bool(forKey: \"overviewCompact\") ? \"Expandir\" : \"Compactar\"\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else {\n compactItem.attributedTitle = NSAttributedString(string: label)\n return\n }\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(string: \"\\(label) \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘E\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n compactItem.attributedTitle = s\n }\n\n /// One Carbon handler serves every hot key; the event's EventHotKeyID\n /// says which one fired.\n private func installHotKeyHandlerOnce() -> Bool {\n guard !swapHotKeyHandlerInstalled else { return true }\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n let status = InstallEventHandler(\n GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: global hot-key handler registration failed (OSStatus %d)\", status)\n return false\n }\n swapHotKeyHandlerInstalled = true\n return true\n }\n\n private func disableFailedShortcut(_ key: String, name: String,\n status: OSStatus? = nil) {\n if let status {\n NSLog(\"ai-usagebar: global shortcut %@ registration failed (OSStatus %d)\",\n name, status)\n } else {\n NSLog(\"ai-usagebar: global shortcut %@ registration failed\", name)\n }\n // Keep the visible preference honest when another app already owns the\n // shortcut or Carbon refuses the registration.\n DEF.set(false, forKey: key)\n }\n\n @discardableResult\n func removeSwapHotKey() -> Bool {\n if let ref = swapHotKeyRef {\n let status = UnregisterEventHotKey(ref)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: could not unregister ⌥⌘\\\\ (OSStatus %d)\", status)\n return false\n }\n swapHotKeyRef = nil\n }\n return true\n }\n\n @discardableResult\n private func removeCompactHotKey() -> Bool {\n if let ref = compactHotKeyRef {\n let status = UnregisterEventHotKey(ref)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: could not unregister ⌥⌘E (OSStatus %d)\", status)\n return false\n }\n compactHotKeyRef = nil\n }\n return true\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// ⌥⌘E: toggle Compactar/Expandir. Overview-only — flipping a hidden\n /// preference from another view would be invisible and confusing.\n @objc func handleCompactHotKey() {\n guard VENDOR == \"overview\" else { return }\n toggleCompact()\n }\n\n /// The swap-shortcut ring: every configured entry (vendors *and* Claude\n /// accounts) plus the synthetic \"overview\" target, so ⌥⌘\\ cycles them all.\n func swapCycleIds() -> [String] {\n var ids = vendorEntries(active: VENDOR).map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // Reads as a sub-line of the header, so it sits above the usage rows.\n accountsInfoItem.isEnabled = false\n accountsInfoItem.isHidden = true\n menu.addItem(accountsInfoItem)\n // One hidden slot per built-in vendor for Overview mode. Named accounts\n // can take the total higher; renderOverview grows the pool on demand.\n for _ in 0..<VENDOR_AUTH.count {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n // First item after the usage rows, Overview-only (hidden elsewhere).\n compactItem.action = #selector(toggleCompact)\n compactItem.target = self\n compactItem.isHidden = true\n menu.addItem(compactItem)\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n for (item, submenu) in [(desktopAccountItem, desktopAccountSubmenu),\n (cliAccountItem, cliAccountSubmenu)] {\n item.submenu = submenu\n item.isHidden = true\n menu.addItem(item)\n }\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n // Catches a switch made elsewhere (a terminal, claude-acc) without\n // polling for state that changes at most a few times a day.\n menu.delegate = self\n statusItem.menu = menu\n }\n\n func menuWillOpen(_ menu: NSMenu) {\n if Date().timeIntervalSince(accountStatusFetchedAt) >= 5 { fetchAccountStatus() }\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n /// Flip the overview's compact mode; the UserDefaults observer re-renders,\n /// which also relabels the item (Compactar ↔ Expandir).\n @objc func toggleCompact() {\n DEF.set(!DEF.bool(forKey: \"overviewCompact\"), forKey: \"overviewCompact\")\n }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n if overviewVisibilityChangePending {\n overviewVisibilityChangePending = false\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov) }\n return\n }\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n if DEF.bool(forKey: \"compactShortcutEnabled\") != (compactHotKeyRef != nil) {\n installCompactHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = entryDisplayName(VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: vendor) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n // vendorEntries(active: \"\") keeps only configured entries — the\n // active-vendor courtesy slot has no place in an all-vendors sweep.\n let entries = filterOverviewEntries(vendorEntries(active: \"\"),\n requested: configuredOverviewVendorIds())\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in entries {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: v.id) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: baseVendorId(v.id))\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// Compact bar label for a vendor name. Usually the first word; for a\n /// Claude account (\"Claude · gmail\") the account label is the part that\n /// distinguishes entries, so use it instead of the shared \"Claude\".\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let base = name.range(of: \" · \").map { String(name[$0.upperBound...]) }\n ?? String(name.split(separator: \" \").first ?? Substring(name))\n return base.count <= maxLen ? base : String(base.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 4) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n // Providers toggled off in the dropdown are dropped from the always-on\n // top summary (but still listed there so they can be re-enabled).\n let hidden = overviewHiddenProviders()\n let visible = Set(overviewVisibleIds(items.map { $0.id }, hidden: hidden))\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String, reset: String?)] =\n items.compactMap {\n guard visible.contains($0.id), let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\", nil) }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\", h.reset.flatMap(shortReset))\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 4\n let compact = DEF.bool(forKey: \"overviewCompact\")\n let t = NSMutableAttributedString()\n if overviewUsesBars(count: heads.count, barsMax: barsMax, compact: compact) {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n // Entry order, not worst-first: entries are provider-grouped\n // (standard Claude, then its accounts, then the other vendors), and\n // stable positions beat a pct sort that reshuffles labels on every\n // refresh.\n for (i, e) in heads.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n }\n if heads.count > maxN { t.append(run(\" +\\(heads.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n ensureOverviewRowCapacity(items.count)\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n let hidden = overviewHiddenProviders()\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to toggle whether this provider shows in the top-bar\n // summary. Checkmark = shown; unchecked + dimmed = hidden from the\n // bar (still listed here so it can be turned back on). Jump-to-vendor\n // moved to the \"Trocar vendor\" submenu and ⌥⌘\\.\n let isHidden = hidden.contains(item.id)\n it.state = isHidden ? .off : .on\n it.representedObject = item.id\n it.target = self\n it.action = #selector(toggleOverviewProvider(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n // Grey the whole row while hidden, so \"off\" reads at a glance.\n if isHidden {\n a.addAttribute(.foregroundColor, value: NSColor.tertiaryLabelColor,\n range: NSRange(location: 0, length: a.length))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n compactItem.isHidden = false\n updateCompactItemTitle()\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n /// Add overview row slots before the single-vendor rows when newly\n /// discovered Claude accounts outgrow the initial built-in-vendor pool.\n private func ensureOverviewRowCapacity(_ count: Int) {\n guard count > overviewRows.count,\n let menu = statusItem.menu,\n let firstDetail = rows[\"session\"] else { return }\n while overviewRows.count < count {\n let item = NSMenuItem()\n item.isHidden = true\n menu.insertItem(item, at: menu.index(of: firstDetail))\n overviewRows.append(item)\n }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: baseVendorId(VENDOR)) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n // With several Claude accounts the plan alone (\"Claude Max 20x\") no\n // longer says WHICH account this is — suffix the label.\n let plan = s.plan.isEmpty ? \"AI Usage\" : s.plan\n let header = accountLabel(of: VENDOR).map { \"\\(plan) · \\($0)\" } ?? plan\n headerItem.attributedTitle = run(header, .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let entries = vendorEntries(active: active)\n if entries.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in entries {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n /// Ask the binary who each surface is signed in as. Same off-main shape as\n /// `refresh()`; failures leave `lastAccountStatus` alone so a transient\n /// hiccup doesn't blank a working menu.\n func fetchAccountStatus() {\n guard let bin = resolveBinary(\"ai-usagebar\") else { return }\n accountStatusFetchedAt = Date()\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = [\"account\", \"status\", \"--json\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var data = Data()\n do {\n try p.run()\n data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n } catch {\n watchdog.cancel()\n return\n }\n watchdog.cancel()\n guard let status = parseAccountStatus(data) else { return }\n DispatchQueue.main.async { self?.applyAccountStatus(status) }\n }\n }\n\n func applyAccountStatus(_ status: AccountStatus) {\n lastAccountStatus = status\n renderAccountMenus()\n }\n\n func renderAccountMenus() {\n // Nil means the binary predates the subcommand: leave the menu exactly\n // as it was before this feature existed.\n guard let status = lastAccountStatus else {\n accountsInfoItem.isHidden = true\n desktopAccountItem.isHidden = true\n cliAccountItem.isHidden = true\n return\n }\n let line = accountsSummaryLine(status)\n accountsInfoItem.isHidden = line.isEmpty\n accountsInfoItem.attributedTitle = run(line, .secondaryLabelColor)\n\n fill(desktopAccountSubmenu, status.desktopLabels, status.desktopActive,\n #selector(switchDesktopAccount(_:)))\n fill(cliAccountSubmenu, status.cliLabels, status.cliActive,\n #selector(switchCliAccount(_:)))\n // Both stay visible with an empty list — that is when \"Adicionar\n // conta…\" matters most. Only a machine with no Claude Desktop app at\n // all loses its submenu.\n desktopAccountItem.isHidden = !status.desktopAvailable\n cliAccountItem.isHidden = false\n desktopAccountItem.isEnabled = !accountSwitchInFlight\n cliAccountItem.isEnabled = !accountSwitchInFlight\n }\n\n private func fill(_ submenu: NSMenu, _ labels: [String], _ active: String?, _ action: Selector) {\n submenu.removeAllItems()\n for label in labels {\n let it = NSMenuItem(title: label, action: action, keyEquivalent: \"\")\n it.target = self\n it.representedObject = label\n it.state = (label == active) ? .on : .off\n it.isEnabled = !accountSwitchInFlight && label != active\n submenu.addItem(it)\n }\n submenu.addItem(.separator())\n let add = NSMenuItem(title: \"Adicionar conta…\",\n action: #selector(addAccount(_:)), keyEquivalent: \"\")\n add.target = self\n add.representedObject = (submenu === desktopAccountSubmenu)\n add.isEnabled = !accountSwitchInFlight\n submenu.addItem(add)\n }\n\n /// Ask for a label, then hand the interactive part to Terminal: a Desktop\n /// capture waits for a browser sign-in, a CLI one runs `claude`. Neither\n /// belongs in a background subprocess the user cannot see or answer.\n @objc func addAccount(_ sender: NSMenuItem) {\n guard let desktop = sender.representedObject as? Bool,\n let bin = resolveBinary(\"ai-usagebar\") else { return }\n let alert = NSAlert()\n alert.messageText = desktop ? \"Adicionar conta do Claude Desktop\"\n : \"Adicionar conta do Claude Code\"\n alert.informativeText = desktop\n ? \"Escolha um nome. O app será fechado e reaberto na tela de login para você \"\n + \"entrar com a conta nova; a atual é restaurada se você cancelar.\"\n : \"Escolha um nome. O `claude` abrirá no Terminal para você entrar; seu login \"\n + \"padrão não é alterado.\"\n let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 220, height: 24))\n field.placeholderString = \"trabalho\"\n alert.accessoryView = field\n alert.addButton(withTitle: \"Continuar\")\n alert.addButton(withTitle: \"Cancelar\")\n NSApp.activate(ignoringOtherApps: true)\n alert.window.initialFirstResponder = field\n guard alert.runModal() == .alertFirstButtonReturn else { return }\n let label = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)\n guard !label.isEmpty else { return }\n runInTerminal(addAccountScript(binary: bin, label: label, desktop: desktop))\n }\n\n /// Quits and reopens Claude.app, so confirm first — an unsent message or an\n /// in-flight Cowork run would go with it. The CLI switch has no visible\n /// side effect and needs no prompt.\n @objc func switchDesktopAccount(_ sender: NSMenuItem) {\n guard let label = sender.representedObject as? String else { return }\n let alert = NSAlert()\n alert.messageText = \"Trocar a conta do Claude Desktop para «\\(label)»?\"\n alert.informativeText = \"O app será fechado e reaberto. Seu histórico local é \"\n + \"mesclado nessa conta antes da troca, e uma cópia de segurança é gravada.\"\n alert.addButton(withTitle: \"Trocar e reiniciar\")\n alert.addButton(withTitle: \"Cancelar\")\n NSApp.activate(ignoringOtherApps: true)\n guard alert.runModal() == .alertFirstButtonReturn else { return }\n runAccountSwitch(label: label, desktop: true)\n }\n\n @objc func switchCliAccount(_ sender: NSMenuItem) {\n guard let label = sender.representedObject as? String else { return }\n runAccountSwitch(label: label, desktop: false)\n }\n\n private func runAccountSwitch(label: String, desktop: Bool) {\n guard let bin = resolveBinary(\"ai-usagebar\"), !accountSwitchInFlight else { return }\n accountSwitchInFlight = true\n renderAccountMenus()\n let args = switchArgs(label: label, desktop: desktop)\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = args\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n // A Desktop switch merges history, archives, quits and relaunches an\n // app — far longer than a usage fetch, so it gets its own bound.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + ACCOUNT_SWITCH_TIMEOUT, execute: watchdog)\n try? p.run()\n p.waitUntilExit()\n watchdog.cancel()\n DispatchQueue.main.async {\n guard let me = self else { return }\n me.accountSwitchInFlight = false\n me.fetchAccountStatus()\n // The usage numbers belong to whoever is signed in now.\n me.refresh()\n }\n }\n }\n\n /// Toggle whether a provider appears in the Overview top-bar summary, from\n /// its dropdown row. The UserDefaults observer re-renders from the last\n /// fetch and consumes this presentation-only change without re-fetching.\n @objc func toggleOverviewProvider(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n overviewVisibilityChangePending = true\n setOverviewProvider(id, hidden: !overviewHiddenProviders().contains(id))\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n accountsInfoItem.isHidden = true\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "199c257030ddcee22ac035001383782ef08911be": {"t": 1785750117.9743695, "n": 105921, "h": "3f213fa2ee8a66df", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\n/// Squeeze a countdown (\"4d 1h\", \"2h 05m\", \"now\") down to its leading unit for\n/// the overview status-bar title, where every character fights for space:\n/// \"4d 1h\" → \"4d\", \"2h 05m\" → \"2h\", \"0h 05m\" → \"5m\". Missing/em-dash → nil.\nfunc shortReset(_ r: String) -> String? {\n guard !r.isEmpty, r != \"—\" else { return nil }\n let parts = r.split(separator: \" \")\n guard let first = parts.first else { return nil }\n if first == \"0h\", parts.count > 1 {\n let m = parts[1].drop(while: { $0 == \"0\" })\n return m == \"m\" ? \"0m\" : String(m)\n }\n return String(first)\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\n/// Read a TOML array of quoted strings. This intentionally stays small (the\n/// native app is a single dependency-free Swift file), but accepts multiline\n/// arrays and rejects malformed values instead of silently widening Overview.\nfunc tomlStringArrayInText(_ text: String, section: String, key: String) -> [String]? {\n func withoutComment(_ line: String) -> String {\n var result = \"\"\n var quote: Character?\n var escaped = false\n for ch in line {\n if let active = quote {\n result.append(ch)\n if active == \"\\\"\", escaped {\n escaped = false\n } else if active == \"\\\"\", ch == \"\\\\\" {\n escaped = true\n } else if ch == active {\n quote = nil\n }\n } else if ch == \"#\" {\n break\n } else {\n result.append(ch)\n if ch == \"\\\"\" || ch == \"'\" { quote = ch }\n }\n }\n return result\n }\n\n func hasClosingBracket(_ value: String) -> Bool {\n var quote: Character?\n var escaped = false\n for ch in value {\n if let active = quote {\n if active == \"\\\"\", escaped {\n escaped = false\n } else if active == \"\\\"\", ch == \"\\\\\" {\n escaped = true\n } else if ch == active {\n quote = nil\n }\n } else if ch == \"\\\"\" || ch == \"'\" {\n quote = ch\n } else if ch == \"]\" {\n return true\n }\n }\n return false\n }\n\n var inSection = false\n var rawValue: String?\n for rawLine in text.components(separatedBy: .newlines) {\n let line = withoutComment(rawLine).trimmingCharacters(in: .whitespaces)\n if let current = rawValue {\n let joined = current + \" \" + line\n rawValue = joined\n if hasClosingBracket(joined) { break }\n continue\n }\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.isEmpty else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n guard value.hasPrefix(\"[\") else { return nil }\n rawValue = value\n if hasClosingBracket(value) { break }\n }\n\n guard let raw = rawValue, hasClosingBracket(raw) else { return nil }\n let chars = Array(raw)\n guard chars.first == \"[\" else { return nil }\n var result: [String] = []\n var i = 1\n\n func skipSpace() {\n while i < chars.count && chars[i].isWhitespace { i += 1 }\n }\n\n while true {\n skipSpace()\n guard i < chars.count else { return nil }\n if chars[i] == \"]\" {\n i += 1\n skipSpace()\n return i == chars.count ? result : nil\n }\n let quote = chars[i]\n guard quote == \"\\\"\" || quote == \"'\" else { return nil }\n i += 1\n var value = \"\"\n var closed = false\n while i < chars.count {\n let ch = chars[i]\n i += 1\n if ch == quote {\n closed = true\n break\n }\n if quote == \"\\\"\", ch == \"\\\\\", i < chars.count {\n value.append(chars[i])\n i += 1\n } else {\n value.append(ch)\n }\n }\n guard closed else { return nil }\n result.append(value)\n skipSpace()\n guard i < chars.count else { return nil }\n if chars[i] == \",\" {\n i += 1\n continue\n }\n guard chars[i] == \"]\" else { return nil }\n }\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc configuredOverviewVendorIds() -> [String]? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlStringArrayInText(text, section: \"ui\", key: \"overview_vendors\")\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n// ── Anthropic multi-account ────────────────────────────────────────────────\n// Mirrors the Rust side (`src/config.rs`): explicit `[[anthropic.accounts]]`\n// entries plus subdirectories of `[anthropic] accounts_dir`, explicit labels\n// winning on a clash. Each account is a\n// selectable entry with the pseudo-id `anthropic@<label>`, fetched by the\n// binary as `--vendor anthropic --account <label>` — the binary resolves the\n// account's credentials itself (file or its own Keychain item), so the app\n// never needs to know where they live.\n\n/// `[[anthropic.accounts]]` labels, in file order. Pure text scan, like the\n/// rest of this app's TOML reading: a `[[anthropic.accounts]]` header opens a\n/// block, the first `label = \"…\"` inside it counts.\nfunc anthropicAccountLabels(inTOML text: String) -> [String] {\n var labels: [String] = []\n var inBlock = false\n for rawLine in text.components(separatedBy: .newlines) {\n let line = rawLine.trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inBlock = line == \"[[anthropic.accounts]]\"\n continue\n }\n guard inBlock, line.hasPrefix(\"label\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == \"label\" else { continue }\n var value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n value = String(content[..<end])\n }\n if !value.isEmpty {\n labels.append(value)\n inBlock = false // one label per block\n }\n }\n return labels\n}\n\n/// Immediate subdirectories of `dir`, sorted — the same discovery rule as the\n/// Rust `accounts_dir` scan. Credentials may be file- or Keychain-backed.\nfunc discoverAccountLabels(inDir dir: String) -> [String] {\n let fm = FileManager.default\n guard let names = try? fm.contentsOfDirectory(atPath: dir) else { return [] }\n return names.filter { name in\n var isDir: ObjCBool = false\n let sub = \"\\(dir)/\\(name)\"\n return fm.fileExists(atPath: sub, isDirectory: &isDir) && isDir.boolValue\n }.sorted()\n}\n\n/// Explicit labels first (file order), then discovered ones that don't clash.\nfunc mergedAccountLabels(explicit: [String], discovered: [String]) -> [String] {\n explicit + discovered.filter { !explicit.contains($0) }\n}\n\n/// All configured Claude account labels, from the same config the binary reads.\nfunc claudeAccountLabels() -> [String] {\n guard let text = try? String(contentsOfFile: configPathTOML(), encoding: .utf8) else {\n return []\n }\n let explicit = anthropicAccountLabels(inTOML: text)\n var discovered: [String] = []\n if let dir = tomlValueInText(text, section: \"anthropic\", key: \"accounts_dir\"), !dir.isEmpty {\n discovered = discoverAccountLabels(inDir: NSString(string: dir).expandingTildeInPath)\n }\n return mergedAccountLabels(explicit: explicit, discovered: discovered)\n}\n\n/// Rust semantics (`show_default_account`): `false` hides the default\n/// (unnamed) Claude entry, but is ignored when there are no named accounts,\n/// so Anthropic never loses its only entry.\nfunc showDefaultClaudeAccount(configValue: String?, hasAccounts: Bool) -> Bool {\n guard hasAccounts else { return true }\n return configValue != \"false\"\n}\n\n/// Pseudo-id mapping: `anthropic@<label>` selects a named account.\nlet ACCOUNT_ID_PREFIX = \"anthropic@\"\n\nfunc accountLabel(of id: String) -> String? {\n id.hasPrefix(ACCOUNT_ID_PREFIX) ? String(id.dropFirst(ACCOUNT_ID_PREFIX.count)) : nil\n}\n\nfunc baseVendorId(_ id: String) -> String {\n accountLabel(of: id) != nil ? \"anthropic\" : id\n}\n\n/// The subprocess arguments that select `id` (base vendor or named account).\nfunc vendorArgs(for id: String) -> [String] {\n if let label = accountLabel(of: id) {\n return [\"--vendor\", \"anthropic\", \"--account\", label]\n }\n return [\"--vendor\", id]\n}\n\n/// One selectable entry: a base vendor or one Claude account.\nstruct MenuEntry {\n let id: String // \"cursor\", \"anthropic\", or \"anthropic@<label>\"\n let name: String // display: \"Cursor\", \"Claude · <label>\"\n}\n\n/// Apply `[ui] overview_vendors` with the same semantics as the TUI: preserve\n/// config order, omit unavailable vendors, and include every named Claude\n/// account when `anthropic` is requested.\nfunc filterOverviewEntries(_ entries: [MenuEntry], requested: [String]?) -> [MenuEntry] {\n guard let requested else { return entries }\n var result: [MenuEntry] = []\n var seen = Set<String>()\n for vendor in requested {\n for entry in entries where baseVendorId(entry.id) == vendor && !seen.contains(entry.id) {\n result.append(entry)\n seen.insert(entry.id)\n }\n }\n return result\n}\n\n/// The selectable entries, in menu order: every enabled+configured vendor,\n/// with Anthropic expanded into its named accounts (the default entry kept\n/// only per `show_default_account`). `active` stays listed even when\n/// unconfigured — same rule the per-vendor list always had.\nfunc vendorEntries(active: String) -> [MenuEntry] {\n var out: [MenuEntry] = []\n for v in VENDOR_AUTH where vendorEnabled(v) {\n if v.id == \"anthropic\" {\n let labels = claudeAccountLabels()\n let showDefault = showDefaultClaudeAccount(\n configValue: configValueTOML(\"anthropic\", \"show_default_account\"),\n hasAccounts: !labels.isEmpty)\n if showDefault && (v.id == active || vendorConfigured(v)) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n for label in labels {\n out.append(MenuEntry(id: ACCOUNT_ID_PREFIX + label, name: \"Claude · \\(label)\"))\n }\n } else if v.id == active || vendorConfigured(v) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n }\n return out\n}\n\n/// Display name for any selectable id (base vendor, account, or overview).\nfunc entryDisplayName(_ id: String) -> String {\n if id == \"overview\" { return \"Visão geral\" }\n if let label = accountLabel(of: id) { return \"Claude · \\(label)\" }\n return VENDOR_AUTH.first { $0.id == id }?.name ?? id\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"compactShortcutEnabled\") private var compactShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n @State private var launchAtLogin = launchAgentIsInstalled()\n @State private var launchAtLoginError: String?\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker. Claude\n // accounts appear as their `anthropic@<label>` pseudo-ids, same as the\n // \"Trocar vendor\" submenu.\n private var vendors: [String] {\n var ids = VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n let labels = claudeAccountLabels()\n if let at = ids.firstIndex(of: \"anthropic\") {\n ids.insert(contentsOf: labels.map { ACCOUNT_ID_PREFIX + $0 }, at: at + 1)\n }\n return ids\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n Toggle(\"Compactar/Expandir com ⌥⌘E (atalho global)\", isOn: $compactShortcutEnabled)\n Text(\"Alterna a Visão geral entre barras e modo compacto.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Sistema\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Iniciar no login\", isOn: Binding(\n get: { launchAtLogin },\n set: { enabled in\n do {\n try setLaunchAtLogin(enabled)\n launchAtLogin = launchAgentIsInstalled()\n launchAtLoginError = nil\n } catch {\n launchAtLogin = launchAgentIsInstalled()\n launchAtLoginError = error.localizedDescription\n }\n }))\n Text(\"Instala um LaunchAgent que sobe o app ao entrar na sua conta.\")\n .font(.caption).foregroundColor(.secondary)\n if let error = launchAtLoginError {\n Text(\"Não foi possível atualizar: \\(error)\")\n .font(.caption).foregroundColor(.red)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n .onAppear { launchAtLogin = launchAgentIsInstalled() }\n }\n}\n\n// ─── Launch at login (macOS LaunchAgent) ─────────────────────────────────\n//\n// The app is an unbundled single binary, so the modern SMAppService.mainApp\n// API (which needs a bundle id) doesn't apply. A per-user LaunchAgent is the\n// portable way to start an unbundled executable at login — the same mechanism\n// install-agent.sh sets up, now toggleable from Preferences.\nlet LAUNCH_AGENT_LABEL = \"com.akitaonrails.ai-usagebar-menubar\"\n\nfunc launchAgentPlistPath() -> String {\n \"\\(NSHomeDirectory())/Library/LaunchAgents/\\(LAUNCH_AGENT_LABEL).plist\"\n}\n\nfunc launchAgentIsInstalled() -> Bool {\n FileManager.default.fileExists(atPath: launchAgentPlistPath())\n}\n\n/// Absolute path of the running executable, for the LaunchAgent to relaunch.\nfunc selfExecutablePath() -> String {\n if let p = Bundle.main.executablePath, !p.isEmpty { return p }\n let arg0 = CommandLine.arguments.first ?? \"ai-usagebar-menubar\"\n if arg0.hasPrefix(\"/\") { return arg0 }\n return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)\n .appendingPathComponent(arg0).standardized.path\n}\n\n/// Install (or remove) the login LaunchAgent by managing its plist file.\n/// Building the plist via PropertyListSerialization sidesteps the XML-escaping\n/// the shell installer has to do by hand.\n///\n/// Deliberately no `launchctl load`/`unload`: the app is already running when\n/// this is toggled, so `RunAtLoad` on load would spawn a *second* copy, and\n/// `unload` on disable would SIGTERM this very process (quitting the app the\n/// user is still using) if it happened to be the launchd-managed one. launchd\n/// loads every agent in ~/Library/LaunchAgents at the next login, so writing\n/// (or removing) the file is all that's needed for a start-at-login toggle.\nfunc launchAgentPlist(executable: String) throws -> Data {\n let plist: [String: Any] = [\n \"Label\": LAUNCH_AGENT_LABEL,\n \"ProgramArguments\": [executable],\n \"RunAtLoad\": true,\n \"ProcessType\": \"Interactive\",\n ]\n return try PropertyListSerialization.data(\n fromPropertyList: plist, format: .xml, options: 0)\n}\n\nfunc setLaunchAtLogin(_ enabled: Bool) throws {\n let path = launchAgentPlistPath()\n if enabled {\n let dir = (path as NSString).deletingLastPathComponent\n try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)\n let data = try launchAgentPlist(executable: selfExecutablePath())\n try data.write(to: URL(fileURLWithPath: path), options: .atomic)\n } else if FileManager.default.fileExists(atPath: path) {\n try FileManager.default.removeItem(atPath: path)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\nlet compactHotKeyNotification = Notification.Name(\"aiusagebar.compactHotKey\")\n\n/// Default shortcuts: `\\` (kVK_ANSI_Backslash) with Command+Option swaps the\n/// vendor; `E` with Command+Option toggles the overview's Compactar/Expandir.\n/// `[ui]` on Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet COMPACT_HOTKEY_KEYCODE = UInt32(kVK_ANSI_E)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\n/// Registered hot-key ids, carried back to us in the event so one shared C\n/// callback can tell which shortcut fired.\nlet SWAP_HOTKEY_ID = UInt32(1)\nlet COMPACT_HOTKEY_ID = UInt32(2)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n var hk = EventHotKeyID()\n GetEventParameter(event, EventParamName(kEventParamDirectObject),\n EventParamType(typeEventHotKeyID), nil,\n MemoryLayout<EventHotKeyID>.size, nil, &hk)\n NotificationCenter.default.post(\n name: hk.id == COMPACT_HOTKEY_ID ? compactHotKeyNotification : swapHotKeyNotification,\n object: nil)\n return noErr\n}\n\n/// Whether the overview status-bar title draws mini bars (vs. the compact\n/// %-text mode). \"Compactar\" forces the text mode even under the bars-count\n/// threshold. Pure + testable — the render path is not.\nfunc overviewUsesBars(count: Int, barsMax: Int, compact: Bool) -> Bool {\n !compact && count <= barsMax\n}\n\n/// Provider ids the user toggled out of the always-visible top-bar summary in\n/// Overview mode. The dropdown still lists them (unchecked + dimmed) so they can\n/// be turned back on. Persisted in UserDefaults; absent key → nothing hidden.\nfunc overviewHiddenProviders() -> Set<String> {\n Set(DEF.stringArray(forKey: \"overviewHiddenProviders\") ?? [])\n}\n\nfunc setOverviewProvider(_ id: String, hidden: Bool) {\n var hiddenSet = overviewHiddenProviders()\n if hidden { hiddenSet.insert(id) } else { hiddenSet.remove(id) }\n DEF.set(hiddenSet.sorted(), forKey: \"overviewHiddenProviders\")\n}\n\n/// The ids that survive the top-bar filter, in their original order. Pure +\n/// testable — the render path (which also drops nil snapshots) is not.\nfunc overviewVisibleIds(_ ids: [String], hidden: Set<String>) -> [String] {\n ids.filter { !hidden.contains($0) }\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nfunc hotKeyRegistrationSucceeded(_ status: OSStatus) -> Bool {\n status == noErr\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var compactHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Live watch on config.toml so external edits (a text editor, the TUI's\n /// Settings overlay, `ai-usagebar account add`) hot-reload\n /// the vendor list without restarting the app. The fd is closed by the\n /// source's cancel handler; `pendingConfigReload` coalesces an editor's\n /// burst of vnode events into a single reload.\n var configWatchSource: DispatchSourceFileSystemObject?\n var configWatchFD: CInt = -1\n var pendingConfigReload: DispatchWorkItem?\n /// A visibility checkbox changes presentation only. Its UserDefaults\n /// notification should repaint from cache without restarting the timer or\n /// launching a fresh round of provider subprocesses.\n var overviewVisibilityChangePending = false\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor/account); hidden in\n // single-vendor mode. The pool starts at the built-in vendor count and can\n // grow if account discovery adds more rows.\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n /// Overview-only: forces the status-bar title into the compact %-text mode\n /// (\"Compactar\"); while compact it reads \"Expandir\" and turns it back off.\n let compactItem = NSMenuItem(title: \"Compactar\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true, \"compactShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleCompactHotKey),\n name: compactHotKeyNotification, object: nil)\n installSwapHotKey()\n installCompactHotKey()\n installConfigWatch()\n }\n\n /// Watch config.toml and hot-reload the vendor list when it changes on disk.\n /// Editors usually save atomically (write a temp file, then rename it over\n /// the target), which unlinks the inode our descriptor points at — so a\n /// `.delete`/`.rename` event means \"re-open the path to keep watching the\n /// new file\". A plain in-place write (`>>`, some editors) fires `.write` on\n /// the same descriptor. Idempotent: always tears down the previous watch.\n func installConfigWatch() {\n removeConfigWatch()\n let path = configPathTOML()\n let fd = open(path, O_EVTONLY)\n guard fd >= 0 else {\n // No readable file yet (not created, or momentarily gone between an\n // editor's unlink and rename). Retry shortly so a config created\n // after launch — or a non-atomic save's brief gap — is still picked\n // up. ponytail: a fixed 3s retry, not exponential backoff; this only\n // spins in the rare window before the file exists.\n DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in\n self?.installConfigWatch()\n }\n return\n }\n let src = DispatchSource.makeFileSystemObjectSource(\n fileDescriptor: fd, eventMask: [.write, .extend, .delete, .rename, .attrib],\n queue: .main)\n src.setEventHandler { [weak self] in\n guard let self, let flags = self.configWatchSource?.data else { return }\n self.scheduleConfigReload()\n // The watched inode was replaced; re-arm on whatever is at the path\n // now so future edits keep firing.\n if flags.contains(.delete) || flags.contains(.rename) {\n self.installConfigWatch()\n }\n }\n src.setCancelHandler { close(fd) }\n configWatchFD = fd\n configWatchSource = src\n src.resume()\n }\n\n func removeConfigWatch() {\n configWatchSource?.cancel() // the cancel handler closes the fd\n configWatchSource = nil\n configWatchFD = -1\n }\n\n /// Coalesce the burst of vnode events a save emits (write → rename → attrib)\n /// into one reload a beat later, mirroring `settingsChanged`'s debounce.\n func scheduleConfigReload() {\n pendingConfigReload?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.configFileChanged() }\n pendingConfigReload = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)\n }\n\n /// A config.toml edit landed: rebuild the vendor submenu/ring, re-render the\n /// current view from cache so an `[ui]` tweak shows at once, then debounce a\n /// full refresh to fetch any newly-added vendor/account. Reads are all fresh\n /// from disk (nothing caches config.toml), so no invalidation is needed.\n @objc func configFileChanged() {\n rebuildVendorSubmenu()\n if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n guard removeSwapHotKey() else {\n // The old registration is still live; reflect that instead of\n // showing a disabled toggle that continues intercepting the key.\n DEF.set(true, forKey: \"swapShortcutEnabled\")\n return\n }\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n guard installHotKeyHandlerOnce() else {\n disableFailedShortcut(\"swapShortcutEnabled\", name: \"⌥⌘\\\\\")\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n return\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: SWAP_HOTKEY_ID) // 'AIBB'\n var ref: EventHotKeyRef?\n let status = RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &ref)\n guard hotKeyRegistrationSucceeded(status), let ref else {\n if let ref { UnregisterEventHotKey(ref) }\n disableFailedShortcut(\"swapShortcutEnabled\", name: \"⌥⌘\\\\\", status: status)\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n return\n }\n swapHotKeyRef = ref\n }\n\n /// (Re)register the global ⌥⌘E Compactar/Expandir hot key to match the\n /// `compactShortcutEnabled` preference. Idempotent, mirroring\n /// `installSwapHotKey` — including the painted-in hint (a native\n /// keyEquivalent would fire a second time while the menu is open, double-\n /// toggling on one press).\n func installCompactHotKey() {\n guard removeCompactHotKey() else {\n DEF.set(true, forKey: \"compactShortcutEnabled\")\n return\n }\n updateCompactItemTitle()\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else { return }\n guard installHotKeyHandlerOnce() else {\n disableFailedShortcut(\"compactShortcutEnabled\", name: \"⌥⌘E\")\n updateCompactItemTitle()\n return\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: COMPACT_HOTKEY_ID)\n var ref: EventHotKeyRef?\n let status = RegisterEventHotKey(\n COMPACT_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &ref)\n guard hotKeyRegistrationSucceeded(status), let ref else {\n if let ref { UnregisterEventHotKey(ref) }\n disableFailedShortcut(\"compactShortcutEnabled\", name: \"⌥⌘E\", status: status)\n updateCompactItemTitle()\n return\n }\n compactHotKeyRef = ref\n }\n\n /// Compactar ↔ Expandir label plus the dimmed ⌥⌘E hint when the global\n /// shortcut is on. Shared by the overview render and the hot-key installer.\n func updateCompactItemTitle() {\n let label = DEF.bool(forKey: \"overviewCompact\") ? \"Expandir\" : \"Compactar\"\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else {\n compactItem.attributedTitle = NSAttributedString(string: label)\n return\n }\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(string: \"\\(label) \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘E\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n compactItem.attributedTitle = s\n }\n\n /// One Carbon handler serves every hot key; the event's EventHotKeyID\n /// says which one fired.\n private func installHotKeyHandlerOnce() -> Bool {\n guard !swapHotKeyHandlerInstalled else { return true }\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n let status = InstallEventHandler(\n GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: global hot-key handler registration failed (OSStatus %d)\", status)\n return false\n }\n swapHotKeyHandlerInstalled = true\n return true\n }\n\n private func disableFailedShortcut(_ key: String, name: String,\n status: OSStatus? = nil) {\n if let status {\n NSLog(\"ai-usagebar: global shortcut %@ registration failed (OSStatus %d)\",\n name, status)\n } else {\n NSLog(\"ai-usagebar: global shortcut %@ registration failed\", name)\n }\n // Keep the visible preference honest when another app already owns the\n // shortcut or Carbon refuses the registration.\n DEF.set(false, forKey: key)\n }\n\n @discardableResult\n func removeSwapHotKey() -> Bool {\n if let ref = swapHotKeyRef {\n let status = UnregisterEventHotKey(ref)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: could not unregister ⌥⌘\\\\ (OSStatus %d)\", status)\n return false\n }\n swapHotKeyRef = nil\n }\n return true\n }\n\n @discardableResult\n private func removeCompactHotKey() -> Bool {\n if let ref = compactHotKeyRef {\n let status = UnregisterEventHotKey(ref)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: could not unregister ⌥⌘E (OSStatus %d)\", status)\n return false\n }\n compactHotKeyRef = nil\n }\n return true\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// ⌥⌘E: toggle Compactar/Expandir. Overview-only — flipping a hidden\n /// preference from another view would be invisible and confusing.\n @objc func handleCompactHotKey() {\n guard VENDOR == \"overview\" else { return }\n toggleCompact()\n }\n\n /// The swap-shortcut ring: every configured entry (vendors *and* Claude\n /// accounts) plus the synthetic \"overview\" target, so ⌥⌘\\ cycles them all.\n func swapCycleIds() -> [String] {\n var ids = vendorEntries(active: VENDOR).map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per built-in vendor for Overview mode. Named accounts\n // can take the total higher; renderOverview grows the pool on demand.\n for _ in 0..<VENDOR_AUTH.count {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n // First item after the usage rows, Overview-only (hidden elsewhere).\n compactItem.action = #selector(toggleCompact)\n compactItem.target = self\n compactItem.isHidden = true\n menu.addItem(compactItem)\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n /// Flip the overview's compact mode; the UserDefaults observer re-renders,\n /// which also relabels the item (Compactar ↔ Expandir).\n @objc func toggleCompact() {\n DEF.set(!DEF.bool(forKey: \"overviewCompact\"), forKey: \"overviewCompact\")\n }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n if overviewVisibilityChangePending {\n overviewVisibilityChangePending = false\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov) }\n return\n }\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n if DEF.bool(forKey: \"compactShortcutEnabled\") != (compactHotKeyRef != nil) {\n installCompactHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = entryDisplayName(VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: vendor) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n // vendorEntries(active: \"\") keeps only configured entries — the\n // active-vendor courtesy slot has no place in an all-vendors sweep.\n let entries = filterOverviewEntries(vendorEntries(active: \"\"),\n requested: configuredOverviewVendorIds())\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in entries {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: v.id) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: baseVendorId(v.id))\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// Compact bar label for a vendor name. Usually the first word; for a\n /// Claude account (\"Claude · gmail\") the account label is the part that\n /// distinguishes entries, so use it instead of the shared \"Claude\".\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let base = name.range(of: \" · \").map { String(name[$0.upperBound...]) }\n ?? String(name.split(separator: \" \").first ?? Substring(name))\n return base.count <= maxLen ? base : String(base.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 4) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n // Providers toggled off in the dropdown are dropped from the always-on\n // top summary (but still listed there so they can be re-enabled).\n let hidden = overviewHiddenProviders()\n let visible = Set(overviewVisibleIds(items.map { $0.id }, hidden: hidden))\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String, reset: String?)] =\n items.compactMap {\n guard visible.contains($0.id), let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\", nil) }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\", h.reset.flatMap(shortReset))\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 4\n let compact = DEF.bool(forKey: \"overviewCompact\")\n let t = NSMutableAttributedString()\n if overviewUsesBars(count: heads.count, barsMax: barsMax, compact: compact) {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n // Entry order, not worst-first: entries are provider-grouped\n // (standard Claude, then its accounts, then the other vendors), and\n // stable positions beat a pct sort that reshuffles labels on every\n // refresh.\n for (i, e) in heads.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n }\n if heads.count > maxN { t.append(run(\" +\\(heads.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n ensureOverviewRowCapacity(items.count)\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n let hidden = overviewHiddenProviders()\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to toggle whether this provider shows in the top-bar\n // summary. Checkmark = shown; unchecked + dimmed = hidden from the\n // bar (still listed here so it can be turned back on). Jump-to-vendor\n // moved to the \"Trocar vendor\" submenu and ⌥⌘\\.\n let isHidden = hidden.contains(item.id)\n it.state = isHidden ? .off : .on\n it.representedObject = item.id\n it.target = self\n it.action = #selector(toggleOverviewProvider(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n // Grey the whole row while hidden, so \"off\" reads at a glance.\n if isHidden {\n a.addAttribute(.foregroundColor, value: NSColor.tertiaryLabelColor,\n range: NSRange(location: 0, length: a.length))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n compactItem.isHidden = false\n updateCompactItemTitle()\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n /// Add overview row slots before the single-vendor rows when newly\n /// discovered Claude accounts outgrow the initial built-in-vendor pool.\n private func ensureOverviewRowCapacity(_ count: Int) {\n guard count > overviewRows.count,\n let menu = statusItem.menu,\n let firstDetail = rows[\"session\"] else { return }\n while overviewRows.count < count {\n let item = NSMenuItem()\n item.isHidden = true\n menu.insertItem(item, at: menu.index(of: firstDetail))\n overviewRows.append(item)\n }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: baseVendorId(VENDOR)) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n // With several Claude accounts the plan alone (\"Claude Max 20x\") no\n // longer says WHICH account this is — suffix the label.\n let plan = s.plan.isEmpty ? \"AI Usage\" : s.plan\n let header = accountLabel(of: VENDOR).map { \"\\(plan) · \\($0)\" } ?? plan\n headerItem.attributedTitle = run(header, .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let entries = vendorEntries(active: active)\n if entries.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in entries {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n /// Toggle whether a provider appears in the Overview top-bar summary, from\n /// its dropdown row. The UserDefaults observer re-renders from the last\n /// fetch and consumes this presentation-only change without re-fetching.\n @objc func toggleOverviewProvider(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n overviewVisibilityChangePending = true\n setOverviewProvider(id, hidden: !overviewHiddenProviders().contains(id))\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "4d3028db06e72a6eb0417324ef50d009694fdd6c": {"t": 1785750119.5850503, "n": 105374, "h": "469af398bddf835a", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\n/// Squeeze a countdown (\"4d 1h\", \"2h 05m\", \"now\") down to its leading unit for\n/// the overview status-bar title, where every character fights for space:\n/// \"4d 1h\" → \"4d\", \"2h 05m\" → \"2h\", \"0h 05m\" → \"5m\". Missing/em-dash → nil.\nfunc shortReset(_ r: String) -> String? {\n guard !r.isEmpty, r != \"—\" else { return nil }\n let parts = r.split(separator: \" \")\n guard let first = parts.first else { return nil }\n if first == \"0h\", parts.count > 1 {\n let m = parts[1].drop(while: { $0 == \"0\" })\n return m == \"m\" ? \"0m\" : String(m)\n }\n return String(first)\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\n/// Read a TOML array of quoted strings. This intentionally stays small (the\n/// native app is a single dependency-free Swift file), but accepts multiline\n/// arrays and rejects malformed values instead of silently widening Overview.\nfunc tomlStringArrayInText(_ text: String, section: String, key: String) -> [String]? {\n func withoutComment(_ line: String) -> String {\n var result = \"\"\n var quote: Character?\n var escaped = false\n for ch in line {\n if let active = quote {\n result.append(ch)\n if active == \"\\\"\", escaped {\n escaped = false\n } else if active == \"\\\"\", ch == \"\\\\\" {\n escaped = true\n } else if ch == active {\n quote = nil\n }\n } else if ch == \"#\" {\n break\n } else {\n result.append(ch)\n if ch == \"\\\"\" || ch == \"'\" { quote = ch }\n }\n }\n return result\n }\n\n func hasClosingBracket(_ value: String) -> Bool {\n var quote: Character?\n var escaped = false\n for ch in value {\n if let active = quote {\n if active == \"\\\"\", escaped {\n escaped = false\n } else if active == \"\\\"\", ch == \"\\\\\" {\n escaped = true\n } else if ch == active {\n quote = nil\n }\n } else if ch == \"\\\"\" || ch == \"'\" {\n quote = ch\n } else if ch == \"]\" {\n return true\n }\n }\n return false\n }\n\n var inSection = false\n var rawValue: String?\n for rawLine in text.components(separatedBy: .newlines) {\n let line = withoutComment(rawLine).trimmingCharacters(in: .whitespaces)\n if let current = rawValue {\n let joined = current + \" \" + line\n rawValue = joined\n if hasClosingBracket(joined) { break }\n continue\n }\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.isEmpty else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n guard value.hasPrefix(\"[\") else { return nil }\n rawValue = value\n if hasClosingBracket(value) { break }\n }\n\n guard let raw = rawValue, hasClosingBracket(raw) else { return nil }\n let chars = Array(raw)\n guard chars.first == \"[\" else { return nil }\n var result: [String] = []\n var i = 1\n\n func skipSpace() {\n while i < chars.count && chars[i].isWhitespace { i += 1 }\n }\n\n while true {\n skipSpace()\n guard i < chars.count else { return nil }\n if chars[i] == \"]\" {\n i += 1\n skipSpace()\n return i == chars.count ? result : nil\n }\n let quote = chars[i]\n guard quote == \"\\\"\" || quote == \"'\" else { return nil }\n i += 1\n var value = \"\"\n var closed = false\n while i < chars.count {\n let ch = chars[i]\n i += 1\n if ch == quote {\n closed = true\n break\n }\n if quote == \"\\\"\", ch == \"\\\\\", i < chars.count {\n value.append(chars[i])\n i += 1\n } else {\n value.append(ch)\n }\n }\n guard closed else { return nil }\n result.append(value)\n skipSpace()\n guard i < chars.count else { return nil }\n if chars[i] == \",\" {\n i += 1\n continue\n }\n guard chars[i] == \"]\" else { return nil }\n }\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc configuredOverviewVendorIds() -> [String]? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlStringArrayInText(text, section: \"ui\", key: \"overview_vendors\")\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n// ── Anthropic multi-account ────────────────────────────────────────────────\n// Mirrors the Rust side (`src/config.rs`): explicit `[[anthropic.accounts]]`\n// entries plus subdirectories of `[anthropic] accounts_dir`, explicit labels\n// winning on a clash. Each account is a\n// selectable entry with the pseudo-id `anthropic@<label>`, fetched by the\n// binary as `--vendor anthropic --account <label>` — the binary resolves the\n// account's credentials itself (file or its own Keychain item), so the app\n// never needs to know where they live.\n\n/// `[[anthropic.accounts]]` labels, in file order. Pure text scan, like the\n/// rest of this app's TOML reading: a `[[anthropic.accounts]]` header opens a\n/// block, the first `label = \"…\"` inside it counts.\nfunc anthropicAccountLabels(inTOML text: String) -> [String] {\n var labels: [String] = []\n var inBlock = false\n for rawLine in text.components(separatedBy: .newlines) {\n let line = rawLine.trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inBlock = line == \"[[anthropic.accounts]]\"\n continue\n }\n guard inBlock, line.hasPrefix(\"label\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == \"label\" else { continue }\n var value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n value = String(content[..<end])\n }\n if !value.isEmpty {\n labels.append(value)\n inBlock = false // one label per block\n }\n }\n return labels\n}\n\n/// Immediate subdirectories of `dir`, sorted — the same discovery rule as the\n/// Rust `accounts_dir` scan. Credentials may be file- or Keychain-backed.\nfunc discoverAccountLabels(inDir dir: String) -> [String] {\n let fm = FileManager.default\n guard let names = try? fm.contentsOfDirectory(atPath: dir) else { return [] }\n return names.filter { name in\n var isDir: ObjCBool = false\n let sub = \"\\(dir)/\\(name)\"\n return fm.fileExists(atPath: sub, isDirectory: &isDir) && isDir.boolValue\n }.sorted()\n}\n\n/// Explicit labels first (file order), then discovered ones that don't clash.\nfunc mergedAccountLabels(explicit: [String], discovered: [String]) -> [String] {\n explicit + discovered.filter { !explicit.contains($0) }\n}\n\n/// All configured Claude account labels, from the same config the binary reads.\nfunc claudeAccountLabels() -> [String] {\n guard let text = try? String(contentsOfFile: configPathTOML(), encoding: .utf8) else {\n return []\n }\n let explicit = anthropicAccountLabels(inTOML: text)\n var discovered: [String] = []\n if let dir = tomlValueInText(text, section: \"anthropic\", key: \"accounts_dir\"), !dir.isEmpty {\n discovered = discoverAccountLabels(inDir: NSString(string: dir).expandingTildeInPath)\n }\n return mergedAccountLabels(explicit: explicit, discovered: discovered)\n}\n\n/// Rust semantics (`show_default_account`): `false` hides the default\n/// (unnamed) Claude entry, but is ignored when there are no named accounts,\n/// so Anthropic never loses its only entry.\nfunc showDefaultClaudeAccount(configValue: String?, hasAccounts: Bool) -> Bool {\n guard hasAccounts else { return true }\n return configValue != \"false\"\n}\n\n/// Pseudo-id mapping: `anthropic@<label>` selects a named account.\nlet ACCOUNT_ID_PREFIX = \"anthropic@\"\n\nfunc accountLabel(of id: String) -> String? {\n id.hasPrefix(ACCOUNT_ID_PREFIX) ? String(id.dropFirst(ACCOUNT_ID_PREFIX.count)) : nil\n}\n\nfunc baseVendorId(_ id: String) -> String {\n accountLabel(of: id) != nil ? \"anthropic\" : id\n}\n\n/// The subprocess arguments that select `id` (base vendor or named account).\nfunc vendorArgs(for id: String) -> [String] {\n if let label = accountLabel(of: id) {\n return [\"--vendor\", \"anthropic\", \"--account\", label]\n }\n return [\"--vendor\", id]\n}\n\n/// One selectable entry: a base vendor or one Claude account.\nstruct MenuEntry {\n let id: String // \"cursor\", \"anthropic\", or \"anthropic@<label>\"\n let name: String // display: \"Cursor\", \"Claude · <label>\"\n}\n\n/// Apply `[ui] overview_vendors` with the same semantics as the TUI: preserve\n/// config order, omit unavailable vendors, and include every named Claude\n/// account when `anthropic` is requested.\nfunc filterOverviewEntries(_ entries: [MenuEntry], requested: [String]?) -> [MenuEntry] {\n guard let requested else { return entries }\n var result: [MenuEntry] = []\n var seen = Set<String>()\n for vendor in requested {\n for entry in entries where baseVendorId(entry.id) == vendor && !seen.contains(entry.id) {\n result.append(entry)\n seen.insert(entry.id)\n }\n }\n return result\n}\n\n/// The selectable entries, in menu order: every enabled+configured vendor,\n/// with Anthropic expanded into its named accounts (the default entry kept\n/// only per `show_default_account`). `active` stays listed even when\n/// unconfigured — same rule the per-vendor list always had.\nfunc vendorEntries(active: String) -> [MenuEntry] {\n var out: [MenuEntry] = []\n for v in VENDOR_AUTH where vendorEnabled(v) {\n if v.id == \"anthropic\" {\n let labels = claudeAccountLabels()\n let showDefault = showDefaultClaudeAccount(\n configValue: configValueTOML(\"anthropic\", \"show_default_account\"),\n hasAccounts: !labels.isEmpty)\n if showDefault && (v.id == active || vendorConfigured(v)) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n for label in labels {\n out.append(MenuEntry(id: ACCOUNT_ID_PREFIX + label, name: \"Claude · \\(label)\"))\n }\n } else if v.id == active || vendorConfigured(v) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n }\n return out\n}\n\n/// Display name for any selectable id (base vendor, account, or overview).\nfunc entryDisplayName(_ id: String) -> String {\n if id == \"overview\" { return \"Visão geral\" }\n if let label = accountLabel(of: id) { return \"Claude · \\(label)\" }\n return VENDOR_AUTH.first { $0.id == id }?.name ?? id\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"compactShortcutEnabled\") private var compactShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n @State private var launchAtLogin = launchAgentIsInstalled()\n @State private var launchAtLoginError: String?\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker. Claude\n // accounts appear as their `anthropic@<label>` pseudo-ids, same as the\n // \"Trocar vendor\" submenu.\n private var vendors: [String] {\n var ids = VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n let labels = claudeAccountLabels()\n if let at = ids.firstIndex(of: \"anthropic\") {\n ids.insert(contentsOf: labels.map { ACCOUNT_ID_PREFIX + $0 }, at: at + 1)\n }\n return ids\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n Toggle(\"Compactar/Expandir com ⌥⌘E (atalho global)\", isOn: $compactShortcutEnabled)\n Text(\"Alterna a Visão geral entre barras e modo compacto.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Sistema\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Iniciar no login\", isOn: Binding(\n get: { launchAtLogin },\n set: { enabled in\n do {\n try setLaunchAtLogin(enabled)\n launchAtLogin = launchAgentIsInstalled()\n launchAtLoginError = nil\n } catch {\n launchAtLogin = launchAgentIsInstalled()\n launchAtLoginError = error.localizedDescription\n }\n }))\n Text(\"Instala um LaunchAgent que sobe o app ao entrar na sua conta.\")\n .font(.caption).foregroundColor(.secondary)\n if let error = launchAtLoginError {\n Text(\"Não foi possível atualizar: \\(error)\")\n .font(.caption).foregroundColor(.red)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n .onAppear { launchAtLogin = launchAgentIsInstalled() }\n }\n}\n\n// ─── Launch at login (macOS LaunchAgent) ─────────────────────────────────\n//\n// The app is an unbundled single binary, so the modern SMAppService.mainApp\n// API (which needs a bundle id) doesn't apply. A per-user LaunchAgent is the\n// portable way to start an unbundled executable at login — the same mechanism\n// install-agent.sh sets up, now toggleable from Preferences.\nlet LAUNCH_AGENT_LABEL = \"com.akitaonrails.ai-usagebar-menubar\"\n\nfunc launchAgentPlistPath() -> String {\n \"\\(NSHomeDirectory())/Library/LaunchAgents/\\(LAUNCH_AGENT_LABEL).plist\"\n}\n\nfunc launchAgentIsInstalled() -> Bool {\n FileManager.default.fileExists(atPath: launchAgentPlistPath())\n}\n\n/// Absolute path of the running executable, for the LaunchAgent to relaunch.\nfunc selfExecutablePath() -> String {\n if let p = Bundle.main.executablePath, !p.isEmpty { return p }\n let arg0 = CommandLine.arguments.first ?? \"ai-usagebar-menubar\"\n if arg0.hasPrefix(\"/\") { return arg0 }\n return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)\n .appendingPathComponent(arg0).standardized.path\n}\n\n/// Install (or remove) the login LaunchAgent by managing its plist file.\n/// Building the plist via PropertyListSerialization sidesteps the XML-escaping\n/// the shell installer has to do by hand.\n///\n/// Deliberately no `launchctl load`/`unload`: the app is already running when\n/// this is toggled, so `RunAtLoad` on load would spawn a *second* copy, and\n/// `unload` on disable would SIGTERM this very process (quitting the app the\n/// user is still using) if it happened to be the launchd-managed one. launchd\n/// loads every agent in ~/Library/LaunchAgents at the next login, so writing\n/// (or removing) the file is all that's needed for a start-at-login toggle.\nfunc launchAgentPlist(executable: String) throws -> Data {\n let plist: [String: Any] = [\n \"Label\": LAUNCH_AGENT_LABEL,\n \"ProgramArguments\": [executable],\n \"RunAtLoad\": true,\n \"ProcessType\": \"Interactive\",\n ]\n return try PropertyListSerialization.data(\n fromPropertyList: plist, format: .xml, options: 0)\n}\n\nfunc setLaunchAtLogin(_ enabled: Bool) throws {\n let path = launchAgentPlistPath()\n if enabled {\n let dir = (path as NSString).deletingLastPathComponent\n try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)\n let data = try launchAgentPlist(executable: selfExecutablePath())\n try data.write(to: URL(fileURLWithPath: path), options: .atomic)\n } else if FileManager.default.fileExists(atPath: path) {\n try FileManager.default.removeItem(atPath: path)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\nlet compactHotKeyNotification = Notification.Name(\"aiusagebar.compactHotKey\")\n\n/// Default shortcuts: `\\` (kVK_ANSI_Backslash) with Command+Option swaps the\n/// vendor; `E` with Command+Option toggles the overview's Compactar/Expandir.\n/// `[ui]` on Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet COMPACT_HOTKEY_KEYCODE = UInt32(kVK_ANSI_E)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\n/// Registered hot-key ids, carried back to us in the event so one shared C\n/// callback can tell which shortcut fired.\nlet SWAP_HOTKEY_ID = UInt32(1)\nlet COMPACT_HOTKEY_ID = UInt32(2)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n var hk = EventHotKeyID()\n GetEventParameter(event, EventParamName(kEventParamDirectObject),\n EventParamType(typeEventHotKeyID), nil,\n MemoryLayout<EventHotKeyID>.size, nil, &hk)\n NotificationCenter.default.post(\n name: hk.id == COMPACT_HOTKEY_ID ? compactHotKeyNotification : swapHotKeyNotification,\n object: nil)\n return noErr\n}\n\n/// Whether the overview status-bar title draws mini bars (vs. the compact\n/// %-text mode). \"Compactar\" forces the text mode even under the bars-count\n/// threshold. Pure + testable — the render path is not.\nfunc overviewUsesBars(count: Int, barsMax: Int, compact: Bool) -> Bool {\n !compact && count <= barsMax\n}\n\n/// Provider ids the user toggled out of the always-visible top-bar summary in\n/// Overview mode. The dropdown still lists them (unchecked + dimmed) so they can\n/// be turned back on. Persisted in UserDefaults; absent key → nothing hidden.\nfunc overviewHiddenProviders() -> Set<String> {\n Set(DEF.stringArray(forKey: \"overviewHiddenProviders\") ?? [])\n}\n\nfunc setOverviewProvider(_ id: String, hidden: Bool) {\n var hiddenSet = overviewHiddenProviders()\n if hidden { hiddenSet.insert(id) } else { hiddenSet.remove(id) }\n DEF.set(hiddenSet.sorted(), forKey: \"overviewHiddenProviders\")\n}\n\n/// The ids that survive the top-bar filter, in their original order. Pure +\n/// testable — the render path (which also drops nil snapshots) is not.\nfunc overviewVisibleIds(_ ids: [String], hidden: Set<String>) -> [String] {\n ids.filter { !hidden.contains($0) }\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nfunc hotKeyRegistrationSucceeded(_ status: OSStatus) -> Bool {\n status == noErr\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var compactHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Live watch on config.toml so external edits (a text editor, the TUI's\n /// Settings overlay, `ai-usagebar account add`) hot-reload\n /// the vendor list without restarting the app. The fd is closed by the\n /// source's cancel handler; `pendingConfigReload` coalesces an editor's\n /// burst of vnode events into a single reload.\n var configWatchSource: DispatchSourceFileSystemObject?\n var configWatchFD: CInt = -1\n var pendingConfigReload: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor/account); hidden in\n // single-vendor mode. The pool starts at the built-in vendor count and can\n // grow if account discovery adds more rows.\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n /// Overview-only: forces the status-bar title into the compact %-text mode\n /// (\"Compactar\"); while compact it reads \"Expandir\" and turns it back off.\n let compactItem = NSMenuItem(title: \"Compactar\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true, \"compactShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleCompactHotKey),\n name: compactHotKeyNotification, object: nil)\n installSwapHotKey()\n installCompactHotKey()\n installConfigWatch()\n }\n\n /// Watch config.toml and hot-reload the vendor list when it changes on disk.\n /// Editors usually save atomically (write a temp file, then rename it over\n /// the target), which unlinks the inode our descriptor points at — so a\n /// `.delete`/`.rename` event means \"re-open the path to keep watching the\n /// new file\". A plain in-place write (`>>`, some editors) fires `.write` on\n /// the same descriptor. Idempotent: always tears down the previous watch.\n func installConfigWatch() {\n removeConfigWatch()\n let path = configPathTOML()\n let fd = open(path, O_EVTONLY)\n guard fd >= 0 else {\n // No readable file yet (not created, or momentarily gone between an\n // editor's unlink and rename). Retry shortly so a config created\n // after launch — or a non-atomic save's brief gap — is still picked\n // up. ponytail: a fixed 3s retry, not exponential backoff; this only\n // spins in the rare window before the file exists.\n DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in\n self?.installConfigWatch()\n }\n return\n }\n let src = DispatchSource.makeFileSystemObjectSource(\n fileDescriptor: fd, eventMask: [.write, .extend, .delete, .rename, .attrib],\n queue: .main)\n src.setEventHandler { [weak self] in\n guard let self, let flags = self.configWatchSource?.data else { return }\n self.scheduleConfigReload()\n // The watched inode was replaced; re-arm on whatever is at the path\n // now so future edits keep firing.\n if flags.contains(.delete) || flags.contains(.rename) {\n self.installConfigWatch()\n }\n }\n src.setCancelHandler { close(fd) }\n configWatchFD = fd\n configWatchSource = src\n src.resume()\n }\n\n func removeConfigWatch() {\n configWatchSource?.cancel() // the cancel handler closes the fd\n configWatchSource = nil\n configWatchFD = -1\n }\n\n /// Coalesce the burst of vnode events a save emits (write → rename → attrib)\n /// into one reload a beat later, mirroring `settingsChanged`'s debounce.\n func scheduleConfigReload() {\n pendingConfigReload?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.configFileChanged() }\n pendingConfigReload = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)\n }\n\n /// A config.toml edit landed: rebuild the vendor submenu/ring, re-render the\n /// current view from cache so an `[ui]` tweak shows at once, then debounce a\n /// full refresh to fetch any newly-added vendor/account. Reads are all fresh\n /// from disk (nothing caches config.toml), so no invalidation is needed.\n @objc func configFileChanged() {\n rebuildVendorSubmenu()\n if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n guard removeSwapHotKey() else {\n // The old registration is still live; reflect that instead of\n // showing a disabled toggle that continues intercepting the key.\n DEF.set(true, forKey: \"swapShortcutEnabled\")\n return\n }\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n guard installHotKeyHandlerOnce() else {\n disableFailedShortcut(\"swapShortcutEnabled\", name: \"⌥⌘\\\\\")\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n return\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: SWAP_HOTKEY_ID) // 'AIBB'\n var ref: EventHotKeyRef?\n let status = RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &ref)\n guard hotKeyRegistrationSucceeded(status), let ref else {\n if let ref { UnregisterEventHotKey(ref) }\n disableFailedShortcut(\"swapShortcutEnabled\", name: \"⌥⌘\\\\\", status: status)\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n return\n }\n swapHotKeyRef = ref\n }\n\n /// (Re)register the global ⌥⌘E Compactar/Expandir hot key to match the\n /// `compactShortcutEnabled` preference. Idempotent, mirroring\n /// `installSwapHotKey` — including the painted-in hint (a native\n /// keyEquivalent would fire a second time while the menu is open, double-\n /// toggling on one press).\n func installCompactHotKey() {\n guard removeCompactHotKey() else {\n DEF.set(true, forKey: \"compactShortcutEnabled\")\n return\n }\n updateCompactItemTitle()\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else { return }\n guard installHotKeyHandlerOnce() else {\n disableFailedShortcut(\"compactShortcutEnabled\", name: \"⌥⌘E\")\n updateCompactItemTitle()\n return\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: COMPACT_HOTKEY_ID)\n var ref: EventHotKeyRef?\n let status = RegisterEventHotKey(\n COMPACT_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &ref)\n guard hotKeyRegistrationSucceeded(status), let ref else {\n if let ref { UnregisterEventHotKey(ref) }\n disableFailedShortcut(\"compactShortcutEnabled\", name: \"⌥⌘E\", status: status)\n updateCompactItemTitle()\n return\n }\n compactHotKeyRef = ref\n }\n\n /// Compactar ↔ Expandir label plus the dimmed ⌥⌘E hint when the global\n /// shortcut is on. Shared by the overview render and the hot-key installer.\n func updateCompactItemTitle() {\n let label = DEF.bool(forKey: \"overviewCompact\") ? \"Expandir\" : \"Compactar\"\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else {\n compactItem.attributedTitle = NSAttributedString(string: label)\n return\n }\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(string: \"\\(label) \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘E\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n compactItem.attributedTitle = s\n }\n\n /// One Carbon handler serves every hot key; the event's EventHotKeyID\n /// says which one fired.\n private func installHotKeyHandlerOnce() -> Bool {\n guard !swapHotKeyHandlerInstalled else { return true }\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n let status = InstallEventHandler(\n GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: global hot-key handler registration failed (OSStatus %d)\", status)\n return false\n }\n swapHotKeyHandlerInstalled = true\n return true\n }\n\n private func disableFailedShortcut(_ key: String, name: String,\n status: OSStatus? = nil) {\n if let status {\n NSLog(\"ai-usagebar: global shortcut %@ registration failed (OSStatus %d)\",\n name, status)\n } else {\n NSLog(\"ai-usagebar: global shortcut %@ registration failed\", name)\n }\n // Keep the visible preference honest when another app already owns the\n // shortcut or Carbon refuses the registration.\n DEF.set(false, forKey: key)\n }\n\n @discardableResult\n func removeSwapHotKey() -> Bool {\n if let ref = swapHotKeyRef {\n let status = UnregisterEventHotKey(ref)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: could not unregister ⌥⌘\\\\ (OSStatus %d)\", status)\n return false\n }\n swapHotKeyRef = nil\n }\n return true\n }\n\n @discardableResult\n private func removeCompactHotKey() -> Bool {\n if let ref = compactHotKeyRef {\n let status = UnregisterEventHotKey(ref)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: could not unregister ⌥⌘E (OSStatus %d)\", status)\n return false\n }\n compactHotKeyRef = nil\n }\n return true\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// ⌥⌘E: toggle Compactar/Expandir. Overview-only — flipping a hidden\n /// preference from another view would be invisible and confusing.\n @objc func handleCompactHotKey() {\n guard VENDOR == \"overview\" else { return }\n toggleCompact()\n }\n\n /// The swap-shortcut ring: every configured entry (vendors *and* Claude\n /// accounts) plus the synthetic \"overview\" target, so ⌥⌘\\ cycles them all.\n func swapCycleIds() -> [String] {\n var ids = vendorEntries(active: VENDOR).map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per built-in vendor for Overview mode. Named accounts\n // can take the total higher; renderOverview grows the pool on demand.\n for _ in 0..<VENDOR_AUTH.count {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n // First item after the usage rows, Overview-only (hidden elsewhere).\n compactItem.action = #selector(toggleCompact)\n compactItem.target = self\n compactItem.isHidden = true\n menu.addItem(compactItem)\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n /// Flip the overview's compact mode; the UserDefaults observer re-renders,\n /// which also relabels the item (Compactar ↔ Expandir).\n @objc func toggleCompact() {\n DEF.set(!DEF.bool(forKey: \"overviewCompact\"), forKey: \"overviewCompact\")\n }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n if DEF.bool(forKey: \"compactShortcutEnabled\") != (compactHotKeyRef != nil) {\n installCompactHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = entryDisplayName(VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: vendor) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n // vendorEntries(active: \"\") keeps only configured entries — the\n // active-vendor courtesy slot has no place in an all-vendors sweep.\n let entries = filterOverviewEntries(vendorEntries(active: \"\"),\n requested: configuredOverviewVendorIds())\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in entries {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: v.id) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: baseVendorId(v.id))\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// Compact bar label for a vendor name. Usually the first word; for a\n /// Claude account (\"Claude · gmail\") the account label is the part that\n /// distinguishes entries, so use it instead of the shared \"Claude\".\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let base = name.range(of: \" · \").map { String(name[$0.upperBound...]) }\n ?? String(name.split(separator: \" \").first ?? Substring(name))\n return base.count <= maxLen ? base : String(base.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 4) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n // Providers toggled off in the dropdown are dropped from the always-on\n // top summary (but still listed there so they can be re-enabled).\n let hidden = overviewHiddenProviders()\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String, reset: String?)] =\n items.compactMap {\n guard !hidden.contains($0.id), let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\", nil) }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\", h.reset.flatMap(shortReset))\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 4\n let compact = DEF.bool(forKey: \"overviewCompact\")\n let t = NSMutableAttributedString()\n if overviewUsesBars(count: heads.count, barsMax: barsMax, compact: compact) {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n // Entry order, not worst-first: entries are provider-grouped\n // (standard Claude, then its accounts, then the other vendors), and\n // stable positions beat a pct sort that reshuffles labels on every\n // refresh.\n for (i, e) in heads.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n }\n if heads.count > maxN { t.append(run(\" +\\(heads.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n ensureOverviewRowCapacity(items.count)\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n let hidden = overviewHiddenProviders()\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to toggle whether this provider shows in the top-bar\n // summary. Checkmark = shown; unchecked + dimmed = hidden from the\n // bar (still listed here so it can be turned back on). Jump-to-vendor\n // moved to the \"Trocar vendor\" submenu and ⌥⌘\\.\n let isHidden = hidden.contains(item.id)\n it.state = isHidden ? .off : .on\n it.representedObject = item.id\n it.target = self\n it.action = #selector(toggleOverviewProvider(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n // Grey the whole row while hidden, so \"off\" reads at a glance.\n if isHidden {\n a.addAttribute(.foregroundColor, value: NSColor.tertiaryLabelColor,\n range: NSRange(location: 0, length: a.length))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n compactItem.isHidden = false\n updateCompactItemTitle()\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n /// Add overview row slots before the single-vendor rows when newly\n /// discovered Claude accounts outgrow the initial built-in-vendor pool.\n private func ensureOverviewRowCapacity(_ count: Int) {\n guard count > overviewRows.count,\n let menu = statusItem.menu,\n let firstDetail = rows[\"session\"] else { return }\n while overviewRows.count < count {\n let item = NSMenuItem()\n item.isHidden = true\n menu.insertItem(item, at: menu.index(of: firstDetail))\n overviewRows.append(item)\n }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: baseVendorId(VENDOR)) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n // With several Claude accounts the plan alone (\"Claude Max 20x\") no\n // longer says WHICH account this is — suffix the label.\n let plan = s.plan.isEmpty ? \"AI Usage\" : s.plan\n let header = accountLabel(of: VENDOR).map { \"\\(plan) · \\($0)\" } ?? plan\n headerItem.attributedTitle = run(header, .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let entries = vendorEntries(active: active)\n if entries.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in entries {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n /// Toggle whether a provider appears in the Overview top-bar summary, from\n /// its dropdown row. Re-renders from the last fetch (visibility only — no\n /// re-fetch), so the bar and the row's checkmark update at once.\n @objc func toggleOverviewProvider(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n setOverviewProvider(id, hidden: !overviewHiddenProviders().contains(id))\n if let ov = lastOverview { renderOverview(ov) }\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "0e1ad1a5c5ec5c346553f09ca3482b8bbc1f885c": {"t": 1785750121.0325124, "n": 103110, "h": "c3af352087663734", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\n/// Squeeze a countdown (\"4d 1h\", \"2h 05m\", \"now\") down to its leading unit for\n/// the overview status-bar title, where every character fights for space:\n/// \"4d 1h\" → \"4d\", \"2h 05m\" → \"2h\", \"0h 05m\" → \"5m\". Missing/em-dash → nil.\nfunc shortReset(_ r: String) -> String? {\n guard !r.isEmpty, r != \"—\" else { return nil }\n let parts = r.split(separator: \" \")\n guard let first = parts.first else { return nil }\n if first == \"0h\", parts.count > 1 {\n let m = parts[1].drop(while: { $0 == \"0\" })\n return m == \"m\" ? \"0m\" : String(m)\n }\n return String(first)\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\n/// Read a TOML array of quoted strings. This intentionally stays small (the\n/// native app is a single dependency-free Swift file), but accepts multiline\n/// arrays and rejects malformed values instead of silently widening Overview.\nfunc tomlStringArrayInText(_ text: String, section: String, key: String) -> [String]? {\n func withoutComment(_ line: String) -> String {\n var result = \"\"\n var quote: Character?\n var escaped = false\n for ch in line {\n if let active = quote {\n result.append(ch)\n if active == \"\\\"\", escaped {\n escaped = false\n } else if active == \"\\\"\", ch == \"\\\\\" {\n escaped = true\n } else if ch == active {\n quote = nil\n }\n } else if ch == \"#\" {\n break\n } else {\n result.append(ch)\n if ch == \"\\\"\" || ch == \"'\" { quote = ch }\n }\n }\n return result\n }\n\n func hasClosingBracket(_ value: String) -> Bool {\n var quote: Character?\n var escaped = false\n for ch in value {\n if let active = quote {\n if active == \"\\\"\", escaped {\n escaped = false\n } else if active == \"\\\"\", ch == \"\\\\\" {\n escaped = true\n } else if ch == active {\n quote = nil\n }\n } else if ch == \"\\\"\" || ch == \"'\" {\n quote = ch\n } else if ch == \"]\" {\n return true\n }\n }\n return false\n }\n\n var inSection = false\n var rawValue: String?\n for rawLine in text.components(separatedBy: .newlines) {\n let line = withoutComment(rawLine).trimmingCharacters(in: .whitespaces)\n if let current = rawValue {\n let joined = current + \" \" + line\n rawValue = joined\n if hasClosingBracket(joined) { break }\n continue\n }\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.isEmpty else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n guard value.hasPrefix(\"[\") else { return nil }\n rawValue = value\n if hasClosingBracket(value) { break }\n }\n\n guard let raw = rawValue, hasClosingBracket(raw) else { return nil }\n let chars = Array(raw)\n guard chars.first == \"[\" else { return nil }\n var result: [String] = []\n var i = 1\n\n func skipSpace() {\n while i < chars.count && chars[i].isWhitespace { i += 1 }\n }\n\n while true {\n skipSpace()\n guard i < chars.count else { return nil }\n if chars[i] == \"]\" {\n i += 1\n skipSpace()\n return i == chars.count ? result : nil\n }\n let quote = chars[i]\n guard quote == \"\\\"\" || quote == \"'\" else { return nil }\n i += 1\n var value = \"\"\n var closed = false\n while i < chars.count {\n let ch = chars[i]\n i += 1\n if ch == quote {\n closed = true\n break\n }\n if quote == \"\\\"\", ch == \"\\\\\", i < chars.count {\n value.append(chars[i])\n i += 1\n } else {\n value.append(ch)\n }\n }\n guard closed else { return nil }\n result.append(value)\n skipSpace()\n guard i < chars.count else { return nil }\n if chars[i] == \",\" {\n i += 1\n continue\n }\n guard chars[i] == \"]\" else { return nil }\n }\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc configuredOverviewVendorIds() -> [String]? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlStringArrayInText(text, section: \"ui\", key: \"overview_vendors\")\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n// ── Anthropic multi-account ────────────────────────────────────────────────\n// Mirrors the Rust side (`src/config.rs`): explicit `[[anthropic.accounts]]`\n// entries plus subdirectories of `[anthropic] accounts_dir`, explicit labels\n// winning on a clash. Each account is a\n// selectable entry with the pseudo-id `anthropic@<label>`, fetched by the\n// binary as `--vendor anthropic --account <label>` — the binary resolves the\n// account's credentials itself (file or its own Keychain item), so the app\n// never needs to know where they live.\n\n/// `[[anthropic.accounts]]` labels, in file order. Pure text scan, like the\n/// rest of this app's TOML reading: a `[[anthropic.accounts]]` header opens a\n/// block, the first `label = \"…\"` inside it counts.\nfunc anthropicAccountLabels(inTOML text: String) -> [String] {\n var labels: [String] = []\n var inBlock = false\n for rawLine in text.components(separatedBy: .newlines) {\n let line = rawLine.trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inBlock = line == \"[[anthropic.accounts]]\"\n continue\n }\n guard inBlock, line.hasPrefix(\"label\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == \"label\" else { continue }\n var value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n value = String(content[..<end])\n }\n if !value.isEmpty {\n labels.append(value)\n inBlock = false // one label per block\n }\n }\n return labels\n}\n\n/// Immediate subdirectories of `dir`, sorted — the same discovery rule as the\n/// Rust `accounts_dir` scan. Credentials may be file- or Keychain-backed.\nfunc discoverAccountLabels(inDir dir: String) -> [String] {\n let fm = FileManager.default\n guard let names = try? fm.contentsOfDirectory(atPath: dir) else { return [] }\n return names.filter { name in\n var isDir: ObjCBool = false\n let sub = \"\\(dir)/\\(name)\"\n return fm.fileExists(atPath: sub, isDirectory: &isDir) && isDir.boolValue\n }.sorted()\n}\n\n/// Explicit labels first (file order), then discovered ones that don't clash.\nfunc mergedAccountLabels(explicit: [String], discovered: [String]) -> [String] {\n explicit + discovered.filter { !explicit.contains($0) }\n}\n\n/// All configured Claude account labels, from the same config the binary reads.\nfunc claudeAccountLabels() -> [String] {\n guard let text = try? String(contentsOfFile: configPathTOML(), encoding: .utf8) else {\n return []\n }\n let explicit = anthropicAccountLabels(inTOML: text)\n var discovered: [String] = []\n if let dir = tomlValueInText(text, section: \"anthropic\", key: \"accounts_dir\"), !dir.isEmpty {\n discovered = discoverAccountLabels(inDir: NSString(string: dir).expandingTildeInPath)\n }\n return mergedAccountLabels(explicit: explicit, discovered: discovered)\n}\n\n/// Rust semantics (`show_default_account`): `false` hides the default\n/// (unnamed) Claude entry, but is ignored when there are no named accounts,\n/// so Anthropic never loses its only entry.\nfunc showDefaultClaudeAccount(configValue: String?, hasAccounts: Bool) -> Bool {\n guard hasAccounts else { return true }\n return configValue != \"false\"\n}\n\n/// Pseudo-id mapping: `anthropic@<label>` selects a named account.\nlet ACCOUNT_ID_PREFIX = \"anthropic@\"\n\nfunc accountLabel(of id: String) -> String? {\n id.hasPrefix(ACCOUNT_ID_PREFIX) ? String(id.dropFirst(ACCOUNT_ID_PREFIX.count)) : nil\n}\n\nfunc baseVendorId(_ id: String) -> String {\n accountLabel(of: id) != nil ? \"anthropic\" : id\n}\n\n/// The subprocess arguments that select `id` (base vendor or named account).\nfunc vendorArgs(for id: String) -> [String] {\n if let label = accountLabel(of: id) {\n return [\"--vendor\", \"anthropic\", \"--account\", label]\n }\n return [\"--vendor\", id]\n}\n\n/// One selectable entry: a base vendor or one Claude account.\nstruct MenuEntry {\n let id: String // \"cursor\", \"anthropic\", or \"anthropic@<label>\"\n let name: String // display: \"Cursor\", \"Claude · <label>\"\n}\n\n/// Apply `[ui] overview_vendors` with the same semantics as the TUI: preserve\n/// config order, omit unavailable vendors, and include every named Claude\n/// account when `anthropic` is requested.\nfunc filterOverviewEntries(_ entries: [MenuEntry], requested: [String]?) -> [MenuEntry] {\n guard let requested else { return entries }\n var result: [MenuEntry] = []\n var seen = Set<String>()\n for vendor in requested {\n for entry in entries where baseVendorId(entry.id) == vendor && !seen.contains(entry.id) {\n result.append(entry)\n seen.insert(entry.id)\n }\n }\n return result\n}\n\n/// The selectable entries, in menu order: every enabled+configured vendor,\n/// with Anthropic expanded into its named accounts (the default entry kept\n/// only per `show_default_account`). `active` stays listed even when\n/// unconfigured — same rule the per-vendor list always had.\nfunc vendorEntries(active: String) -> [MenuEntry] {\n var out: [MenuEntry] = []\n for v in VENDOR_AUTH where vendorEnabled(v) {\n if v.id == \"anthropic\" {\n let labels = claudeAccountLabels()\n let showDefault = showDefaultClaudeAccount(\n configValue: configValueTOML(\"anthropic\", \"show_default_account\"),\n hasAccounts: !labels.isEmpty)\n if showDefault && (v.id == active || vendorConfigured(v)) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n for label in labels {\n out.append(MenuEntry(id: ACCOUNT_ID_PREFIX + label, name: \"Claude · \\(label)\"))\n }\n } else if v.id == active || vendorConfigured(v) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n }\n return out\n}\n\n/// Display name for any selectable id (base vendor, account, or overview).\nfunc entryDisplayName(_ id: String) -> String {\n if id == \"overview\" { return \"Visão geral\" }\n if let label = accountLabel(of: id) { return \"Claude · \\(label)\" }\n return VENDOR_AUTH.first { $0.id == id }?.name ?? id\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"compactShortcutEnabled\") private var compactShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n @State private var launchAtLogin = launchAgentIsInstalled()\n @State private var launchAtLoginError: String?\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker. Claude\n // accounts appear as their `anthropic@<label>` pseudo-ids, same as the\n // \"Trocar vendor\" submenu.\n private var vendors: [String] {\n var ids = VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n let labels = claudeAccountLabels()\n if let at = ids.firstIndex(of: \"anthropic\") {\n ids.insert(contentsOf: labels.map { ACCOUNT_ID_PREFIX + $0 }, at: at + 1)\n }\n return ids\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n Toggle(\"Compactar/Expandir com ⌥⌘E (atalho global)\", isOn: $compactShortcutEnabled)\n Text(\"Alterna a Visão geral entre barras e modo compacto.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Sistema\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Iniciar no login\", isOn: Binding(\n get: { launchAtLogin },\n set: { enabled in\n do {\n try setLaunchAtLogin(enabled)\n launchAtLogin = launchAgentIsInstalled()\n launchAtLoginError = nil\n } catch {\n launchAtLogin = launchAgentIsInstalled()\n launchAtLoginError = error.localizedDescription\n }\n }))\n Text(\"Instala um LaunchAgent que sobe o app ao entrar na sua conta.\")\n .font(.caption).foregroundColor(.secondary)\n if let error = launchAtLoginError {\n Text(\"Não foi possível atualizar: \\(error)\")\n .font(.caption).foregroundColor(.red)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n .onAppear { launchAtLogin = launchAgentIsInstalled() }\n }\n}\n\n// ─── Launch at login (macOS LaunchAgent) ─────────────────────────────────\n//\n// The app is an unbundled single binary, so the modern SMAppService.mainApp\n// API (which needs a bundle id) doesn't apply. A per-user LaunchAgent is the\n// portable way to start an unbundled executable at login — the same mechanism\n// install-agent.sh sets up, now toggleable from Preferences.\nlet LAUNCH_AGENT_LABEL = \"com.akitaonrails.ai-usagebar-menubar\"\n\nfunc launchAgentPlistPath() -> String {\n \"\\(NSHomeDirectory())/Library/LaunchAgents/\\(LAUNCH_AGENT_LABEL).plist\"\n}\n\nfunc launchAgentIsInstalled() -> Bool {\n FileManager.default.fileExists(atPath: launchAgentPlistPath())\n}\n\n/// Absolute path of the running executable, for the LaunchAgent to relaunch.\nfunc selfExecutablePath() -> String {\n if let p = Bundle.main.executablePath, !p.isEmpty { return p }\n let arg0 = CommandLine.arguments.first ?? \"ai-usagebar-menubar\"\n if arg0.hasPrefix(\"/\") { return arg0 }\n return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)\n .appendingPathComponent(arg0).standardized.path\n}\n\n/// Install (or remove) the login LaunchAgent by managing its plist file.\n/// Building the plist via PropertyListSerialization sidesteps the XML-escaping\n/// the shell installer has to do by hand.\n///\n/// Deliberately no `launchctl load`/`unload`: the app is already running when\n/// this is toggled, so `RunAtLoad` on load would spawn a *second* copy, and\n/// `unload` on disable would SIGTERM this very process (quitting the app the\n/// user is still using) if it happened to be the launchd-managed one. launchd\n/// loads every agent in ~/Library/LaunchAgents at the next login, so writing\n/// (or removing) the file is all that's needed for a start-at-login toggle.\nfunc launchAgentPlist(executable: String) throws -> Data {\n let plist: [String: Any] = [\n \"Label\": LAUNCH_AGENT_LABEL,\n \"ProgramArguments\": [executable],\n \"RunAtLoad\": true,\n \"ProcessType\": \"Interactive\",\n ]\n return try PropertyListSerialization.data(\n fromPropertyList: plist, format: .xml, options: 0)\n}\n\nfunc setLaunchAtLogin(_ enabled: Bool) throws {\n let path = launchAgentPlistPath()\n if enabled {\n let dir = (path as NSString).deletingLastPathComponent\n try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)\n let data = try launchAgentPlist(executable: selfExecutablePath())\n try data.write(to: URL(fileURLWithPath: path), options: .atomic)\n } else if FileManager.default.fileExists(atPath: path) {\n try FileManager.default.removeItem(atPath: path)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\nlet compactHotKeyNotification = Notification.Name(\"aiusagebar.compactHotKey\")\n\n/// Default shortcuts: `\\` (kVK_ANSI_Backslash) with Command+Option swaps the\n/// vendor; `E` with Command+Option toggles the overview's Compactar/Expandir.\n/// `[ui]` on Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet COMPACT_HOTKEY_KEYCODE = UInt32(kVK_ANSI_E)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\n/// Registered hot-key ids, carried back to us in the event so one shared C\n/// callback can tell which shortcut fired.\nlet SWAP_HOTKEY_ID = UInt32(1)\nlet COMPACT_HOTKEY_ID = UInt32(2)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n var hk = EventHotKeyID()\n GetEventParameter(event, EventParamName(kEventParamDirectObject),\n EventParamType(typeEventHotKeyID), nil,\n MemoryLayout<EventHotKeyID>.size, nil, &hk)\n NotificationCenter.default.post(\n name: hk.id == COMPACT_HOTKEY_ID ? compactHotKeyNotification : swapHotKeyNotification,\n object: nil)\n return noErr\n}\n\n/// Whether the overview status-bar title draws mini bars (vs. the compact\n/// %-text mode). \"Compactar\" forces the text mode even under the bars-count\n/// threshold. Pure + testable — the render path is not.\nfunc overviewUsesBars(count: Int, barsMax: Int, compact: Bool) -> Bool {\n !compact && count <= barsMax\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nfunc hotKeyRegistrationSucceeded(_ status: OSStatus) -> Bool {\n status == noErr\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var compactHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Live watch on config.toml so external edits (a text editor, the TUI's\n /// Settings overlay, `ai-usagebar account add`) hot-reload\n /// the vendor list without restarting the app. The fd is closed by the\n /// source's cancel handler; `pendingConfigReload` coalesces an editor's\n /// burst of vnode events into a single reload.\n var configWatchSource: DispatchSourceFileSystemObject?\n var configWatchFD: CInt = -1\n var pendingConfigReload: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor/account); hidden in\n // single-vendor mode. The pool starts at the built-in vendor count and can\n // grow if account discovery adds more rows.\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n /// Overview-only: forces the status-bar title into the compact %-text mode\n /// (\"Compactar\"); while compact it reads \"Expandir\" and turns it back off.\n let compactItem = NSMenuItem(title: \"Compactar\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true, \"compactShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleCompactHotKey),\n name: compactHotKeyNotification, object: nil)\n installSwapHotKey()\n installCompactHotKey()\n installConfigWatch()\n }\n\n /// Watch config.toml and hot-reload the vendor list when it changes on disk.\n /// Editors usually save atomically (write a temp file, then rename it over\n /// the target), which unlinks the inode our descriptor points at — so a\n /// `.delete`/`.rename` event means \"re-open the path to keep watching the\n /// new file\". A plain in-place write (`>>`, some editors) fires `.write` on\n /// the same descriptor. Idempotent: always tears down the previous watch.\n func installConfigWatch() {\n removeConfigWatch()\n let path = configPathTOML()\n let fd = open(path, O_EVTONLY)\n guard fd >= 0 else {\n // No readable file yet (not created, or momentarily gone between an\n // editor's unlink and rename). Retry shortly so a config created\n // after launch — or a non-atomic save's brief gap — is still picked\n // up. ponytail: a fixed 3s retry, not exponential backoff; this only\n // spins in the rare window before the file exists.\n DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in\n self?.installConfigWatch()\n }\n return\n }\n let src = DispatchSource.makeFileSystemObjectSource(\n fileDescriptor: fd, eventMask: [.write, .extend, .delete, .rename, .attrib],\n queue: .main)\n src.setEventHandler { [weak self] in\n guard let self, let flags = self.configWatchSource?.data else { return }\n self.scheduleConfigReload()\n // The watched inode was replaced; re-arm on whatever is at the path\n // now so future edits keep firing.\n if flags.contains(.delete) || flags.contains(.rename) {\n self.installConfigWatch()\n }\n }\n src.setCancelHandler { close(fd) }\n configWatchFD = fd\n configWatchSource = src\n src.resume()\n }\n\n func removeConfigWatch() {\n configWatchSource?.cancel() // the cancel handler closes the fd\n configWatchSource = nil\n configWatchFD = -1\n }\n\n /// Coalesce the burst of vnode events a save emits (write → rename → attrib)\n /// into one reload a beat later, mirroring `settingsChanged`'s debounce.\n func scheduleConfigReload() {\n pendingConfigReload?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.configFileChanged() }\n pendingConfigReload = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)\n }\n\n /// A config.toml edit landed: rebuild the vendor submenu/ring, re-render the\n /// current view from cache so an `[ui]` tweak shows at once, then debounce a\n /// full refresh to fetch any newly-added vendor/account. Reads are all fresh\n /// from disk (nothing caches config.toml), so no invalidation is needed.\n @objc func configFileChanged() {\n rebuildVendorSubmenu()\n if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n guard removeSwapHotKey() else {\n // The old registration is still live; reflect that instead of\n // showing a disabled toggle that continues intercepting the key.\n DEF.set(true, forKey: \"swapShortcutEnabled\")\n return\n }\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n guard installHotKeyHandlerOnce() else {\n disableFailedShortcut(\"swapShortcutEnabled\", name: \"⌥⌘\\\\\")\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n return\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: SWAP_HOTKEY_ID) // 'AIBB'\n var ref: EventHotKeyRef?\n let status = RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &ref)\n guard hotKeyRegistrationSucceeded(status), let ref else {\n if let ref { UnregisterEventHotKey(ref) }\n disableFailedShortcut(\"swapShortcutEnabled\", name: \"⌥⌘\\\\\", status: status)\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n return\n }\n swapHotKeyRef = ref\n }\n\n /// (Re)register the global ⌥⌘E Compactar/Expandir hot key to match the\n /// `compactShortcutEnabled` preference. Idempotent, mirroring\n /// `installSwapHotKey` — including the painted-in hint (a native\n /// keyEquivalent would fire a second time while the menu is open, double-\n /// toggling on one press).\n func installCompactHotKey() {\n guard removeCompactHotKey() else {\n DEF.set(true, forKey: \"compactShortcutEnabled\")\n return\n }\n updateCompactItemTitle()\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else { return }\n guard installHotKeyHandlerOnce() else {\n disableFailedShortcut(\"compactShortcutEnabled\", name: \"⌥⌘E\")\n updateCompactItemTitle()\n return\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: COMPACT_HOTKEY_ID)\n var ref: EventHotKeyRef?\n let status = RegisterEventHotKey(\n COMPACT_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &ref)\n guard hotKeyRegistrationSucceeded(status), let ref else {\n if let ref { UnregisterEventHotKey(ref) }\n disableFailedShortcut(\"compactShortcutEnabled\", name: \"⌥⌘E\", status: status)\n updateCompactItemTitle()\n return\n }\n compactHotKeyRef = ref\n }\n\n /// Compactar ↔ Expandir label plus the dimmed ⌥⌘E hint when the global\n /// shortcut is on. Shared by the overview render and the hot-key installer.\n func updateCompactItemTitle() {\n let label = DEF.bool(forKey: \"overviewCompact\") ? \"Expandir\" : \"Compactar\"\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else {\n compactItem.attributedTitle = NSAttributedString(string: label)\n return\n }\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(string: \"\\(label) \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘E\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n compactItem.attributedTitle = s\n }\n\n /// One Carbon handler serves every hot key; the event's EventHotKeyID\n /// says which one fired.\n private func installHotKeyHandlerOnce() -> Bool {\n guard !swapHotKeyHandlerInstalled else { return true }\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n let status = InstallEventHandler(\n GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: global hot-key handler registration failed (OSStatus %d)\", status)\n return false\n }\n swapHotKeyHandlerInstalled = true\n return true\n }\n\n private func disableFailedShortcut(_ key: String, name: String,\n status: OSStatus? = nil) {\n if let status {\n NSLog(\"ai-usagebar: global shortcut %@ registration failed (OSStatus %d)\",\n name, status)\n } else {\n NSLog(\"ai-usagebar: global shortcut %@ registration failed\", name)\n }\n // Keep the visible preference honest when another app already owns the\n // shortcut or Carbon refuses the registration.\n DEF.set(false, forKey: key)\n }\n\n @discardableResult\n func removeSwapHotKey() -> Bool {\n if let ref = swapHotKeyRef {\n let status = UnregisterEventHotKey(ref)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: could not unregister ⌥⌘\\\\ (OSStatus %d)\", status)\n return false\n }\n swapHotKeyRef = nil\n }\n return true\n }\n\n @discardableResult\n private func removeCompactHotKey() -> Bool {\n if let ref = compactHotKeyRef {\n let status = UnregisterEventHotKey(ref)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: could not unregister ⌥⌘E (OSStatus %d)\", status)\n return false\n }\n compactHotKeyRef = nil\n }\n return true\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// ⌥⌘E: toggle Compactar/Expandir. Overview-only — flipping a hidden\n /// preference from another view would be invisible and confusing.\n @objc func handleCompactHotKey() {\n guard VENDOR == \"overview\" else { return }\n toggleCompact()\n }\n\n /// The swap-shortcut ring: every configured entry (vendors *and* Claude\n /// accounts) plus the synthetic \"overview\" target, so ⌥⌘\\ cycles them all.\n func swapCycleIds() -> [String] {\n var ids = vendorEntries(active: VENDOR).map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per built-in vendor for Overview mode. Named accounts\n // can take the total higher; renderOverview grows the pool on demand.\n for _ in 0..<VENDOR_AUTH.count {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n // First item after the usage rows, Overview-only (hidden elsewhere).\n compactItem.action = #selector(toggleCompact)\n compactItem.target = self\n compactItem.isHidden = true\n menu.addItem(compactItem)\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n /// Flip the overview's compact mode; the UserDefaults observer re-renders,\n /// which also relabels the item (Compactar ↔ Expandir).\n @objc func toggleCompact() {\n DEF.set(!DEF.bool(forKey: \"overviewCompact\"), forKey: \"overviewCompact\")\n }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n if DEF.bool(forKey: \"compactShortcutEnabled\") != (compactHotKeyRef != nil) {\n installCompactHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = entryDisplayName(VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: vendor) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n // vendorEntries(active: \"\") keeps only configured entries — the\n // active-vendor courtesy slot has no place in an all-vendors sweep.\n let entries = filterOverviewEntries(vendorEntries(active: \"\"),\n requested: configuredOverviewVendorIds())\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in entries {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: v.id) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: baseVendorId(v.id))\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// Compact bar label for a vendor name. Usually the first word; for a\n /// Claude account (\"Claude · gmail\") the account label is the part that\n /// distinguishes entries, so use it instead of the shared \"Claude\".\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let base = name.range(of: \" · \").map { String(name[$0.upperBound...]) }\n ?? String(name.split(separator: \" \").first ?? Substring(name))\n return base.count <= maxLen ? base : String(base.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 4) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String, reset: String?)] =\n items.compactMap {\n guard let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\", nil) }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\", h.reset.flatMap(shortReset))\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 4\n let compact = DEF.bool(forKey: \"overviewCompact\")\n let t = NSMutableAttributedString()\n if overviewUsesBars(count: heads.count, barsMax: barsMax, compact: compact) {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n // Entry order, not worst-first: entries are provider-grouped\n // (standard Claude, then its accounts, then the other vendors), and\n // stable positions beat a pct sort that reshuffles labels on every\n // refresh.\n for (i, e) in heads.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n }\n if heads.count > maxN { t.append(run(\" +\\(heads.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n ensureOverviewRowCapacity(items.count)\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to jump straight to that vendor's detail view.\n it.representedObject = item.id\n it.target = self\n it.action = #selector(switchVendor(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n compactItem.isHidden = false\n updateCompactItemTitle()\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n /// Add overview row slots before the single-vendor rows when newly\n /// discovered Claude accounts outgrow the initial built-in-vendor pool.\n private func ensureOverviewRowCapacity(_ count: Int) {\n guard count > overviewRows.count,\n let menu = statusItem.menu,\n let firstDetail = rows[\"session\"] else { return }\n while overviewRows.count < count {\n let item = NSMenuItem()\n item.isHidden = true\n menu.insertItem(item, at: menu.index(of: firstDetail))\n overviewRows.append(item)\n }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: baseVendorId(VENDOR)) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n // With several Claude accounts the plan alone (\"Claude Max 20x\") no\n // longer says WHICH account this is — suffix the label.\n let plan = s.plan.isEmpty ? \"AI Usage\" : s.plan\n let header = accountLabel(of: VENDOR).map { \"\\(plan) · \\($0)\" } ?? plan\n headerItem.attributedTitle = run(header, .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let entries = vendorEntries(active: active)\n if entries.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in entries {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "ee7f4107e1fb964778028030b876fce1d93b64c5": {"t": 1785750122.5428133, "n": 105390, "h": "9666dc72b29a7489", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\n/// Squeeze a countdown (\"4d 1h\", \"2h 05m\", \"now\") down to its leading unit for\n/// the overview status-bar title, where every character fights for space:\n/// \"4d 1h\" → \"4d\", \"2h 05m\" → \"2h\", \"0h 05m\" → \"5m\". Missing/em-dash → nil.\nfunc shortReset(_ r: String) -> String? {\n guard !r.isEmpty, r != \"—\" else { return nil }\n let parts = r.split(separator: \" \")\n guard let first = parts.first else { return nil }\n if first == \"0h\", parts.count > 1 {\n let m = parts[1].drop(while: { $0 == \"0\" })\n return m == \"m\" ? \"0m\" : String(m)\n }\n return String(first)\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\n/// Read a TOML array of quoted strings. This intentionally stays small (the\n/// native app is a single dependency-free Swift file), but accepts multiline\n/// arrays and rejects malformed values instead of silently widening Overview.\nfunc tomlStringArrayInText(_ text: String, section: String, key: String) -> [String]? {\n func withoutComment(_ line: String) -> String {\n var result = \"\"\n var quote: Character?\n var escaped = false\n for ch in line {\n if let active = quote {\n result.append(ch)\n if active == \"\\\"\", escaped {\n escaped = false\n } else if active == \"\\\"\", ch == \"\\\\\" {\n escaped = true\n } else if ch == active {\n quote = nil\n }\n } else if ch == \"#\" {\n break\n } else {\n result.append(ch)\n if ch == \"\\\"\" || ch == \"'\" { quote = ch }\n }\n }\n return result\n }\n\n func hasClosingBracket(_ value: String) -> Bool {\n var quote: Character?\n var escaped = false\n for ch in value {\n if let active = quote {\n if active == \"\\\"\", escaped {\n escaped = false\n } else if active == \"\\\"\", ch == \"\\\\\" {\n escaped = true\n } else if ch == active {\n quote = nil\n }\n } else if ch == \"\\\"\" || ch == \"'\" {\n quote = ch\n } else if ch == \"]\" {\n return true\n }\n }\n return false\n }\n\n var inSection = false\n var rawValue: String?\n for rawLine in text.components(separatedBy: .newlines) {\n let line = withoutComment(rawLine).trimmingCharacters(in: .whitespaces)\n if let current = rawValue {\n let joined = current + \" \" + line\n rawValue = joined\n if hasClosingBracket(joined) { break }\n continue\n }\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.isEmpty else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n guard value.hasPrefix(\"[\") else { return nil }\n rawValue = value\n if hasClosingBracket(value) { break }\n }\n\n guard let raw = rawValue, hasClosingBracket(raw) else { return nil }\n let chars = Array(raw)\n guard chars.first == \"[\" else { return nil }\n var result: [String] = []\n var i = 1\n\n func skipSpace() {\n while i < chars.count && chars[i].isWhitespace { i += 1 }\n }\n\n while true {\n skipSpace()\n guard i < chars.count else { return nil }\n if chars[i] == \"]\" {\n i += 1\n skipSpace()\n return i == chars.count ? result : nil\n }\n let quote = chars[i]\n guard quote == \"\\\"\" || quote == \"'\" else { return nil }\n i += 1\n var value = \"\"\n var closed = false\n while i < chars.count {\n let ch = chars[i]\n i += 1\n if ch == quote {\n closed = true\n break\n }\n if quote == \"\\\"\", ch == \"\\\\\", i < chars.count {\n value.append(chars[i])\n i += 1\n } else {\n value.append(ch)\n }\n }\n guard closed else { return nil }\n result.append(value)\n skipSpace()\n guard i < chars.count else { return nil }\n if chars[i] == \",\" {\n i += 1\n continue\n }\n guard chars[i] == \"]\" else { return nil }\n }\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc configuredOverviewVendorIds() -> [String]? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlStringArrayInText(text, section: \"ui\", key: \"overview_vendors\")\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n// ── Anthropic multi-account ────────────────────────────────────────────────\n// Mirrors the Rust side (`src/config.rs`): explicit `[[anthropic.accounts]]`\n// entries plus subdirectories of `[anthropic] accounts_dir`, explicit labels\n// winning on a clash. Each account is a\n// selectable entry with the pseudo-id `anthropic@<label>`, fetched by the\n// binary as `--vendor anthropic --account <label>` — the binary resolves the\n// account's credentials itself (file or its own Keychain item), so the app\n// never needs to know where they live.\n\n/// `[[anthropic.accounts]]` labels, in file order. Pure text scan, like the\n/// rest of this app's TOML reading: a `[[anthropic.accounts]]` header opens a\n/// block, the first `label = \"…\"` inside it counts.\nfunc anthropicAccountLabels(inTOML text: String) -> [String] {\n var labels: [String] = []\n var inBlock = false\n for rawLine in text.components(separatedBy: .newlines) {\n let line = rawLine.trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inBlock = line == \"[[anthropic.accounts]]\"\n continue\n }\n guard inBlock, line.hasPrefix(\"label\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == \"label\" else { continue }\n var value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n value = String(content[..<end])\n }\n if !value.isEmpty {\n labels.append(value)\n inBlock = false // one label per block\n }\n }\n return labels\n}\n\n/// Immediate subdirectories of `dir`, sorted — the same discovery rule as the\n/// Rust `accounts_dir` scan. Credentials may be file- or Keychain-backed.\nfunc discoverAccountLabels(inDir dir: String) -> [String] {\n let fm = FileManager.default\n guard let names = try? fm.contentsOfDirectory(atPath: dir) else { return [] }\n return names.filter { name in\n var isDir: ObjCBool = false\n let sub = \"\\(dir)/\\(name)\"\n return fm.fileExists(atPath: sub, isDirectory: &isDir) && isDir.boolValue\n }.sorted()\n}\n\n/// Explicit labels first (file order), then discovered ones that don't clash.\nfunc mergedAccountLabels(explicit: [String], discovered: [String]) -> [String] {\n explicit + discovered.filter { !explicit.contains($0) }\n}\n\n/// All configured Claude account labels, from the same config the binary reads.\nfunc claudeAccountLabels() -> [String] {\n guard let text = try? String(contentsOfFile: configPathTOML(), encoding: .utf8) else {\n return []\n }\n let explicit = anthropicAccountLabels(inTOML: text)\n var discovered: [String] = []\n if let dir = tomlValueInText(text, section: \"anthropic\", key: \"accounts_dir\"), !dir.isEmpty {\n discovered = discoverAccountLabels(inDir: NSString(string: dir).expandingTildeInPath)\n }\n return mergedAccountLabels(explicit: explicit, discovered: discovered)\n}\n\n/// Rust semantics (`show_default_account`): `false` hides the default\n/// (unnamed) Claude entry, but is ignored when there are no named accounts,\n/// so Anthropic never loses its only entry.\nfunc showDefaultClaudeAccount(configValue: String?, hasAccounts: Bool) -> Bool {\n guard hasAccounts else { return true }\n return configValue != \"false\"\n}\n\n/// Pseudo-id mapping: `anthropic@<label>` selects a named account.\nlet ACCOUNT_ID_PREFIX = \"anthropic@\"\n\nfunc accountLabel(of id: String) -> String? {\n id.hasPrefix(ACCOUNT_ID_PREFIX) ? String(id.dropFirst(ACCOUNT_ID_PREFIX.count)) : nil\n}\n\nfunc baseVendorId(_ id: String) -> String {\n accountLabel(of: id) != nil ? \"anthropic\" : id\n}\n\n/// The subprocess arguments that select `id` (base vendor or named account).\nfunc vendorArgs(for id: String) -> [String] {\n if let label = accountLabel(of: id) {\n return [\"--vendor\", \"anthropic\", \"--account\", label]\n }\n return [\"--vendor\", id]\n}\n\n/// One selectable entry: a base vendor or one Claude account.\nstruct MenuEntry {\n let id: String // \"cursor\", \"anthropic\", or \"anthropic@<label>\"\n let name: String // display: \"Cursor\", \"Claude · <label>\"\n}\n\n/// Apply `[ui] overview_vendors` with the same semantics as the TUI: preserve\n/// config order, omit unavailable vendors, and include every named Claude\n/// account when `anthropic` is requested.\nfunc filterOverviewEntries(_ entries: [MenuEntry], requested: [String]?) -> [MenuEntry] {\n guard let requested else { return entries }\n var result: [MenuEntry] = []\n var seen = Set<String>()\n for vendor in requested {\n for entry in entries where baseVendorId(entry.id) == vendor && !seen.contains(entry.id) {\n result.append(entry)\n seen.insert(entry.id)\n }\n }\n return result\n}\n\n/// The selectable entries, in menu order: every enabled+configured vendor,\n/// with Anthropic expanded into its named accounts (the default entry kept\n/// only per `show_default_account`). `active` stays listed even when\n/// unconfigured — same rule the per-vendor list always had.\nfunc vendorEntries(active: String) -> [MenuEntry] {\n var out: [MenuEntry] = []\n for v in VENDOR_AUTH where vendorEnabled(v) {\n if v.id == \"anthropic\" {\n let labels = claudeAccountLabels()\n let showDefault = showDefaultClaudeAccount(\n configValue: configValueTOML(\"anthropic\", \"show_default_account\"),\n hasAccounts: !labels.isEmpty)\n if showDefault && (v.id == active || vendorConfigured(v)) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n for label in labels {\n out.append(MenuEntry(id: ACCOUNT_ID_PREFIX + label, name: \"Claude · \\(label)\"))\n }\n } else if v.id == active || vendorConfigured(v) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n }\n return out\n}\n\n/// Display name for any selectable id (base vendor, account, or overview).\nfunc entryDisplayName(_ id: String) -> String {\n if id == \"overview\" { return \"Visão geral\" }\n if let label = accountLabel(of: id) { return \"Claude · \\(label)\" }\n return VENDOR_AUTH.first { $0.id == id }?.name ?? id\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"compactShortcutEnabled\") private var compactShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n @State private var launchAtLogin = launchAgentIsInstalled()\n @State private var launchAtLoginError: String?\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker. Claude\n // accounts appear as their `anthropic@<label>` pseudo-ids, same as the\n // \"Trocar vendor\" submenu.\n private var vendors: [String] {\n var ids = VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n let labels = claudeAccountLabels()\n if let at = ids.firstIndex(of: \"anthropic\") {\n ids.insert(contentsOf: labels.map { ACCOUNT_ID_PREFIX + $0 }, at: at + 1)\n }\n return ids\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n Toggle(\"Compactar/Expandir com ⌥⌘E (atalho global)\", isOn: $compactShortcutEnabled)\n Text(\"Alterna a Visão geral entre barras e modo compacto.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Sistema\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Iniciar no login\", isOn: Binding(\n get: { launchAtLogin },\n set: { enabled in\n do {\n try setLaunchAtLogin(enabled)\n launchAtLogin = launchAgentIsInstalled()\n launchAtLoginError = nil\n } catch {\n launchAtLogin = launchAgentIsInstalled()\n launchAtLoginError = error.localizedDescription\n }\n }))\n Text(\"Instala um LaunchAgent que sobe o app ao entrar na sua conta.\")\n .font(.caption).foregroundColor(.secondary)\n if let error = launchAtLoginError {\n Text(\"Não foi possível atualizar: \\(error)\")\n .font(.caption).foregroundColor(.red)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n .onAppear { launchAtLogin = launchAgentIsInstalled() }\n }\n}\n\n// ─── Launch at login (macOS LaunchAgent) ─────────────────────────────────\n//\n// The app is an unbundled single binary, so the modern SMAppService.mainApp\n// API (which needs a bundle id) doesn't apply. A per-user LaunchAgent is the\n// portable way to start an unbundled executable at login — the same mechanism\n// install-agent.sh sets up, now toggleable from Preferences.\nlet LAUNCH_AGENT_LABEL = \"com.akitaonrails.ai-usagebar-menubar\"\n\nfunc launchAgentPlistPath() -> String {\n \"\\(NSHomeDirectory())/Library/LaunchAgents/\\(LAUNCH_AGENT_LABEL).plist\"\n}\n\nfunc launchAgentIsInstalled() -> Bool {\n FileManager.default.fileExists(atPath: launchAgentPlistPath())\n}\n\n/// Absolute path of the running executable, for the LaunchAgent to relaunch.\nfunc selfExecutablePath() -> String {\n if let p = Bundle.main.executablePath, !p.isEmpty { return p }\n let arg0 = CommandLine.arguments.first ?? \"ai-usagebar-menubar\"\n if arg0.hasPrefix(\"/\") { return arg0 }\n return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)\n .appendingPathComponent(arg0).standardized.path\n}\n\n/// Install (or remove) the login LaunchAgent by managing its plist file.\n/// Building the plist via PropertyListSerialization sidesteps the XML-escaping\n/// the shell installer has to do by hand.\n///\n/// Deliberately no `launchctl load`/`unload`: the app is already running when\n/// this is toggled, so `RunAtLoad` on load would spawn a *second* copy, and\n/// `unload` on disable would SIGTERM this very process (quitting the app the\n/// user is still using) if it happened to be the launchd-managed one. launchd\n/// loads every agent in ~/Library/LaunchAgents at the next login, so writing\n/// (or removing) the file is all that's needed for a start-at-login toggle.\nfunc launchAgentPlist(executable: String) throws -> Data {\n let plist: [String: Any] = [\n \"Label\": LAUNCH_AGENT_LABEL,\n \"ProgramArguments\": [executable],\n \"RunAtLoad\": true,\n \"ProcessType\": \"Interactive\",\n ]\n return try PropertyListSerialization.data(\n fromPropertyList: plist, format: .xml, options: 0)\n}\n\nfunc setLaunchAtLogin(_ enabled: Bool) throws {\n let path = launchAgentPlistPath()\n if enabled {\n let dir = (path as NSString).deletingLastPathComponent\n try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)\n let data = try launchAgentPlist(executable: selfExecutablePath())\n try data.write(to: URL(fileURLWithPath: path), options: .atomic)\n } else if FileManager.default.fileExists(atPath: path) {\n try FileManager.default.removeItem(atPath: path)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\nlet compactHotKeyNotification = Notification.Name(\"aiusagebar.compactHotKey\")\n\n/// Default shortcuts: `\\` (kVK_ANSI_Backslash) with Command+Option swaps the\n/// vendor; `E` with Command+Option toggles the overview's Compactar/Expandir.\n/// `[ui]` on Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet COMPACT_HOTKEY_KEYCODE = UInt32(kVK_ANSI_E)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\n/// Registered hot-key ids, carried back to us in the event so one shared C\n/// callback can tell which shortcut fired.\nlet SWAP_HOTKEY_ID = UInt32(1)\nlet COMPACT_HOTKEY_ID = UInt32(2)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n var hk = EventHotKeyID()\n GetEventParameter(event, EventParamName(kEventParamDirectObject),\n EventParamType(typeEventHotKeyID), nil,\n MemoryLayout<EventHotKeyID>.size, nil, &hk)\n NotificationCenter.default.post(\n name: hk.id == COMPACT_HOTKEY_ID ? compactHotKeyNotification : swapHotKeyNotification,\n object: nil)\n return noErr\n}\n\n/// Whether the overview status-bar title draws mini bars (vs. the compact\n/// %-text mode). \"Compactar\" forces the text mode even under the bars-count\n/// threshold. Pure + testable — the render path is not.\nfunc overviewUsesBars(count: Int, barsMax: Int, compact: Bool) -> Bool {\n !compact && count <= barsMax\n}\n\n/// Provider ids the user toggled out of the always-visible top-bar summary in\n/// Overview mode. The dropdown still lists them (unchecked + dimmed) so they can\n/// be turned back on. Persisted in UserDefaults; absent key → nothing hidden.\nfunc overviewHiddenProviders() -> Set<String> {\n Set(DEF.stringArray(forKey: \"overviewHiddenProviders\") ?? [])\n}\n\nfunc setOverviewProvider(_ id: String, hidden: Bool) {\n var hiddenSet = overviewHiddenProviders()\n if hidden { hiddenSet.insert(id) } else { hiddenSet.remove(id) }\n DEF.set(hiddenSet.sorted(), forKey: \"overviewHiddenProviders\")\n}\n\n/// The ids that survive the top-bar filter, in their original order. Pure +\n/// testable — the render path (which also drops nil snapshots) is not.\nfunc overviewVisibleIds(_ ids: [String], hidden: Set<String>) -> [String] {\n ids.filter { !hidden.contains($0) }\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nfunc hotKeyRegistrationSucceeded(_ status: OSStatus) -> Bool {\n status == noErr\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var compactHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Live watch on config.toml so external edits (a text editor, the TUI's\n /// Settings overlay, `ai-usagebar --add-custom-claude-account`) hot-reload\n /// the vendor list without restarting the app. The fd is closed by the\n /// source's cancel handler; `pendingConfigReload` coalesces an editor's\n /// burst of vnode events into a single reload.\n var configWatchSource: DispatchSourceFileSystemObject?\n var configWatchFD: CInt = -1\n var pendingConfigReload: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor/account); hidden in\n // single-vendor mode. The pool starts at the built-in vendor count and can\n // grow if account discovery adds more rows.\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n /// Overview-only: forces the status-bar title into the compact %-text mode\n /// (\"Compactar\"); while compact it reads \"Expandir\" and turns it back off.\n let compactItem = NSMenuItem(title: \"Compactar\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true, \"compactShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleCompactHotKey),\n name: compactHotKeyNotification, object: nil)\n installSwapHotKey()\n installCompactHotKey()\n installConfigWatch()\n }\n\n /// Watch config.toml and hot-reload the vendor list when it changes on disk.\n /// Editors usually save atomically (write a temp file, then rename it over\n /// the target), which unlinks the inode our descriptor points at — so a\n /// `.delete`/`.rename` event means \"re-open the path to keep watching the\n /// new file\". A plain in-place write (`>>`, some editors) fires `.write` on\n /// the same descriptor. Idempotent: always tears down the previous watch.\n func installConfigWatch() {\n removeConfigWatch()\n let path = configPathTOML()\n let fd = open(path, O_EVTONLY)\n guard fd >= 0 else {\n // No readable file yet (not created, or momentarily gone between an\n // editor's unlink and rename). Retry shortly so a config created\n // after launch — or a non-atomic save's brief gap — is still picked\n // up. ponytail: a fixed 3s retry, not exponential backoff; this only\n // spins in the rare window before the file exists.\n DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in\n self?.installConfigWatch()\n }\n return\n }\n let src = DispatchSource.makeFileSystemObjectSource(\n fileDescriptor: fd, eventMask: [.write, .extend, .delete, .rename, .attrib],\n queue: .main)\n src.setEventHandler { [weak self] in\n guard let self, let flags = self.configWatchSource?.data else { return }\n self.scheduleConfigReload()\n // The watched inode was replaced; re-arm on whatever is at the path\n // now so future edits keep firing.\n if flags.contains(.delete) || flags.contains(.rename) {\n self.installConfigWatch()\n }\n }\n src.setCancelHandler { close(fd) }\n configWatchFD = fd\n configWatchSource = src\n src.resume()\n }\n\n func removeConfigWatch() {\n configWatchSource?.cancel() // the cancel handler closes the fd\n configWatchSource = nil\n configWatchFD = -1\n }\n\n /// Coalesce the burst of vnode events a save emits (write → rename → attrib)\n /// into one reload a beat later, mirroring `settingsChanged`'s debounce.\n func scheduleConfigReload() {\n pendingConfigReload?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.configFileChanged() }\n pendingConfigReload = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)\n }\n\n /// A config.toml edit landed: rebuild the vendor submenu/ring, re-render the\n /// current view from cache so an `[ui]` tweak shows at once, then debounce a\n /// full refresh to fetch any newly-added vendor/account. Reads are all fresh\n /// from disk (nothing caches config.toml), so no invalidation is needed.\n @objc func configFileChanged() {\n rebuildVendorSubmenu()\n if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n guard removeSwapHotKey() else {\n // The old registration is still live; reflect that instead of\n // showing a disabled toggle that continues intercepting the key.\n DEF.set(true, forKey: \"swapShortcutEnabled\")\n return\n }\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n guard installHotKeyHandlerOnce() else {\n disableFailedShortcut(\"swapShortcutEnabled\", name: \"⌥⌘\\\\\")\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n return\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: SWAP_HOTKEY_ID) // 'AIBB'\n var ref: EventHotKeyRef?\n let status = RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &ref)\n guard hotKeyRegistrationSucceeded(status), let ref else {\n if let ref { UnregisterEventHotKey(ref) }\n disableFailedShortcut(\"swapShortcutEnabled\", name: \"⌥⌘\\\\\", status: status)\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n return\n }\n swapHotKeyRef = ref\n }\n\n /// (Re)register the global ⌥⌘E Compactar/Expandir hot key to match the\n /// `compactShortcutEnabled` preference. Idempotent, mirroring\n /// `installSwapHotKey` — including the painted-in hint (a native\n /// keyEquivalent would fire a second time while the menu is open, double-\n /// toggling on one press).\n func installCompactHotKey() {\n guard removeCompactHotKey() else {\n DEF.set(true, forKey: \"compactShortcutEnabled\")\n return\n }\n updateCompactItemTitle()\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else { return }\n guard installHotKeyHandlerOnce() else {\n disableFailedShortcut(\"compactShortcutEnabled\", name: \"⌥⌘E\")\n updateCompactItemTitle()\n return\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: COMPACT_HOTKEY_ID)\n var ref: EventHotKeyRef?\n let status = RegisterEventHotKey(\n COMPACT_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &ref)\n guard hotKeyRegistrationSucceeded(status), let ref else {\n if let ref { UnregisterEventHotKey(ref) }\n disableFailedShortcut(\"compactShortcutEnabled\", name: \"⌥⌘E\", status: status)\n updateCompactItemTitle()\n return\n }\n compactHotKeyRef = ref\n }\n\n /// Compactar ↔ Expandir label plus the dimmed ⌥⌘E hint when the global\n /// shortcut is on. Shared by the overview render and the hot-key installer.\n func updateCompactItemTitle() {\n let label = DEF.bool(forKey: \"overviewCompact\") ? \"Expandir\" : \"Compactar\"\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else {\n compactItem.attributedTitle = NSAttributedString(string: label)\n return\n }\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(string: \"\\(label) \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘E\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n compactItem.attributedTitle = s\n }\n\n /// One Carbon handler serves every hot key; the event's EventHotKeyID\n /// says which one fired.\n private func installHotKeyHandlerOnce() -> Bool {\n guard !swapHotKeyHandlerInstalled else { return true }\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n let status = InstallEventHandler(\n GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: global hot-key handler registration failed (OSStatus %d)\", status)\n return false\n }\n swapHotKeyHandlerInstalled = true\n return true\n }\n\n private func disableFailedShortcut(_ key: String, name: String,\n status: OSStatus? = nil) {\n if let status {\n NSLog(\"ai-usagebar: global shortcut %@ registration failed (OSStatus %d)\",\n name, status)\n } else {\n NSLog(\"ai-usagebar: global shortcut %@ registration failed\", name)\n }\n // Keep the visible preference honest when another app already owns the\n // shortcut or Carbon refuses the registration.\n DEF.set(false, forKey: key)\n }\n\n @discardableResult\n func removeSwapHotKey() -> Bool {\n if let ref = swapHotKeyRef {\n let status = UnregisterEventHotKey(ref)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: could not unregister ⌥⌘\\\\ (OSStatus %d)\", status)\n return false\n }\n swapHotKeyRef = nil\n }\n return true\n }\n\n @discardableResult\n private func removeCompactHotKey() -> Bool {\n if let ref = compactHotKeyRef {\n let status = UnregisterEventHotKey(ref)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: could not unregister ⌥⌘E (OSStatus %d)\", status)\n return false\n }\n compactHotKeyRef = nil\n }\n return true\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// ⌥⌘E: toggle Compactar/Expandir. Overview-only — flipping a hidden\n /// preference from another view would be invisible and confusing.\n @objc func handleCompactHotKey() {\n guard VENDOR == \"overview\" else { return }\n toggleCompact()\n }\n\n /// The swap-shortcut ring: every configured entry (vendors *and* Claude\n /// accounts) plus the synthetic \"overview\" target, so ⌥⌘\\ cycles them all.\n func swapCycleIds() -> [String] {\n var ids = vendorEntries(active: VENDOR).map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per built-in vendor for Overview mode. Named accounts\n // can take the total higher; renderOverview grows the pool on demand.\n for _ in 0..<VENDOR_AUTH.count {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n // First item after the usage rows, Overview-only (hidden elsewhere).\n compactItem.action = #selector(toggleCompact)\n compactItem.target = self\n compactItem.isHidden = true\n menu.addItem(compactItem)\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n /// Flip the overview's compact mode; the UserDefaults observer re-renders,\n /// which also relabels the item (Compactar ↔ Expandir).\n @objc func toggleCompact() {\n DEF.set(!DEF.bool(forKey: \"overviewCompact\"), forKey: \"overviewCompact\")\n }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n if DEF.bool(forKey: \"compactShortcutEnabled\") != (compactHotKeyRef != nil) {\n installCompactHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = entryDisplayName(VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: vendor) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n // vendorEntries(active: \"\") keeps only configured entries — the\n // active-vendor courtesy slot has no place in an all-vendors sweep.\n let entries = filterOverviewEntries(vendorEntries(active: \"\"),\n requested: configuredOverviewVendorIds())\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in entries {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: v.id) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: baseVendorId(v.id))\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// Compact bar label for a vendor name. Usually the first word; for a\n /// Claude account (\"Claude · gmail\") the account label is the part that\n /// distinguishes entries, so use it instead of the shared \"Claude\".\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let base = name.range(of: \" · \").map { String(name[$0.upperBound...]) }\n ?? String(name.split(separator: \" \").first ?? Substring(name))\n return base.count <= maxLen ? base : String(base.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 4) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n // Providers toggled off in the dropdown are dropped from the always-on\n // top summary (but still listed there so they can be re-enabled).\n let hidden = overviewHiddenProviders()\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String, reset: String?)] =\n items.compactMap {\n guard !hidden.contains($0.id), let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\", nil) }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\", h.reset.flatMap(shortReset))\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 4\n let compact = DEF.bool(forKey: \"overviewCompact\")\n let t = NSMutableAttributedString()\n if overviewUsesBars(count: heads.count, barsMax: barsMax, compact: compact) {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n // Entry order, not worst-first: entries are provider-grouped\n // (standard Claude, then its accounts, then the other vendors), and\n // stable positions beat a pct sort that reshuffles labels on every\n // refresh.\n for (i, e) in heads.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n }\n if heads.count > maxN { t.append(run(\" +\\(heads.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n ensureOverviewRowCapacity(items.count)\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n let hidden = overviewHiddenProviders()\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to toggle whether this provider shows in the top-bar\n // summary. Checkmark = shown; unchecked + dimmed = hidden from the\n // bar (still listed here so it can be turned back on). Jump-to-vendor\n // moved to the \"Trocar vendor\" submenu and ⌥⌘\\.\n let isHidden = hidden.contains(item.id)\n it.state = isHidden ? .off : .on\n it.representedObject = item.id\n it.target = self\n it.action = #selector(toggleOverviewProvider(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n // Grey the whole row while hidden, so \"off\" reads at a glance.\n if isHidden {\n a.addAttribute(.foregroundColor, value: NSColor.tertiaryLabelColor,\n range: NSRange(location: 0, length: a.length))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n compactItem.isHidden = false\n updateCompactItemTitle()\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n /// Add overview row slots before the single-vendor rows when newly\n /// discovered Claude accounts outgrow the initial built-in-vendor pool.\n private func ensureOverviewRowCapacity(_ count: Int) {\n guard count > overviewRows.count,\n let menu = statusItem.menu,\n let firstDetail = rows[\"session\"] else { return }\n while overviewRows.count < count {\n let item = NSMenuItem()\n item.isHidden = true\n menu.insertItem(item, at: menu.index(of: firstDetail))\n overviewRows.append(item)\n }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: baseVendorId(VENDOR)) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n // With several Claude accounts the plan alone (\"Claude Max 20x\") no\n // longer says WHICH account this is — suffix the label.\n let plan = s.plan.isEmpty ? \"AI Usage\" : s.plan\n let header = accountLabel(of: VENDOR).map { \"\\(plan) · \\($0)\" } ?? plan\n headerItem.attributedTitle = run(header, .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let entries = vendorEntries(active: active)\n if entries.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in entries {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n /// Toggle whether a provider appears in the Overview top-bar summary, from\n /// its dropdown row. Re-renders from the last fetch (visibility only — no\n /// re-fetch), so the bar and the row's checkmark update at once.\n @objc func toggleOverviewProvider(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n setOverviewProvider(id, hidden: !overviewHiddenProviders().contains(id))\n if let ov = lastOverview { renderOverview(ov) }\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "4cccd564fffa31a8cf6ddcf620a6eb7faf59261b": {"t": 1785750124.0184517, "n": 103126, "h": "ae645b46574314b8", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\n/// Squeeze a countdown (\"4d 1h\", \"2h 05m\", \"now\") down to its leading unit for\n/// the overview status-bar title, where every character fights for space:\n/// \"4d 1h\" → \"4d\", \"2h 05m\" → \"2h\", \"0h 05m\" → \"5m\". Missing/em-dash → nil.\nfunc shortReset(_ r: String) -> String? {\n guard !r.isEmpty, r != \"—\" else { return nil }\n let parts = r.split(separator: \" \")\n guard let first = parts.first else { return nil }\n if first == \"0h\", parts.count > 1 {\n let m = parts[1].drop(while: { $0 == \"0\" })\n return m == \"m\" ? \"0m\" : String(m)\n }\n return String(first)\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\n/// Read a TOML array of quoted strings. This intentionally stays small (the\n/// native app is a single dependency-free Swift file), but accepts multiline\n/// arrays and rejects malformed values instead of silently widening Overview.\nfunc tomlStringArrayInText(_ text: String, section: String, key: String) -> [String]? {\n func withoutComment(_ line: String) -> String {\n var result = \"\"\n var quote: Character?\n var escaped = false\n for ch in line {\n if let active = quote {\n result.append(ch)\n if active == \"\\\"\", escaped {\n escaped = false\n } else if active == \"\\\"\", ch == \"\\\\\" {\n escaped = true\n } else if ch == active {\n quote = nil\n }\n } else if ch == \"#\" {\n break\n } else {\n result.append(ch)\n if ch == \"\\\"\" || ch == \"'\" { quote = ch }\n }\n }\n return result\n }\n\n func hasClosingBracket(_ value: String) -> Bool {\n var quote: Character?\n var escaped = false\n for ch in value {\n if let active = quote {\n if active == \"\\\"\", escaped {\n escaped = false\n } else if active == \"\\\"\", ch == \"\\\\\" {\n escaped = true\n } else if ch == active {\n quote = nil\n }\n } else if ch == \"\\\"\" || ch == \"'\" {\n quote = ch\n } else if ch == \"]\" {\n return true\n }\n }\n return false\n }\n\n var inSection = false\n var rawValue: String?\n for rawLine in text.components(separatedBy: .newlines) {\n let line = withoutComment(rawLine).trimmingCharacters(in: .whitespaces)\n if let current = rawValue {\n let joined = current + \" \" + line\n rawValue = joined\n if hasClosingBracket(joined) { break }\n continue\n }\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.isEmpty else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n guard value.hasPrefix(\"[\") else { return nil }\n rawValue = value\n if hasClosingBracket(value) { break }\n }\n\n guard let raw = rawValue, hasClosingBracket(raw) else { return nil }\n let chars = Array(raw)\n guard chars.first == \"[\" else { return nil }\n var result: [String] = []\n var i = 1\n\n func skipSpace() {\n while i < chars.count && chars[i].isWhitespace { i += 1 }\n }\n\n while true {\n skipSpace()\n guard i < chars.count else { return nil }\n if chars[i] == \"]\" {\n i += 1\n skipSpace()\n return i == chars.count ? result : nil\n }\n let quote = chars[i]\n guard quote == \"\\\"\" || quote == \"'\" else { return nil }\n i += 1\n var value = \"\"\n var closed = false\n while i < chars.count {\n let ch = chars[i]\n i += 1\n if ch == quote {\n closed = true\n break\n }\n if quote == \"\\\"\", ch == \"\\\\\", i < chars.count {\n value.append(chars[i])\n i += 1\n } else {\n value.append(ch)\n }\n }\n guard closed else { return nil }\n result.append(value)\n skipSpace()\n guard i < chars.count else { return nil }\n if chars[i] == \",\" {\n i += 1\n continue\n }\n guard chars[i] == \"]\" else { return nil }\n }\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc configuredOverviewVendorIds() -> [String]? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlStringArrayInText(text, section: \"ui\", key: \"overview_vendors\")\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n// ── Anthropic multi-account ────────────────────────────────────────────────\n// Mirrors the Rust side (`src/config.rs`): explicit `[[anthropic.accounts]]`\n// entries plus subdirectories of `[anthropic] accounts_dir`, explicit labels\n// winning on a clash. Each account is a\n// selectable entry with the pseudo-id `anthropic@<label>`, fetched by the\n// binary as `--vendor anthropic --account <label>` — the binary resolves the\n// account's credentials itself (file or its own Keychain item), so the app\n// never needs to know where they live.\n\n/// `[[anthropic.accounts]]` labels, in file order. Pure text scan, like the\n/// rest of this app's TOML reading: a `[[anthropic.accounts]]` header opens a\n/// block, the first `label = \"…\"` inside it counts.\nfunc anthropicAccountLabels(inTOML text: String) -> [String] {\n var labels: [String] = []\n var inBlock = false\n for rawLine in text.components(separatedBy: .newlines) {\n let line = rawLine.trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inBlock = line == \"[[anthropic.accounts]]\"\n continue\n }\n guard inBlock, line.hasPrefix(\"label\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == \"label\" else { continue }\n var value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n value = String(content[..<end])\n }\n if !value.isEmpty {\n labels.append(value)\n inBlock = false // one label per block\n }\n }\n return labels\n}\n\n/// Immediate subdirectories of `dir`, sorted — the same discovery rule as the\n/// Rust `accounts_dir` scan. Credentials may be file- or Keychain-backed.\nfunc discoverAccountLabels(inDir dir: String) -> [String] {\n let fm = FileManager.default\n guard let names = try? fm.contentsOfDirectory(atPath: dir) else { return [] }\n return names.filter { name in\n var isDir: ObjCBool = false\n let sub = \"\\(dir)/\\(name)\"\n return fm.fileExists(atPath: sub, isDirectory: &isDir) && isDir.boolValue\n }.sorted()\n}\n\n/// Explicit labels first (file order), then discovered ones that don't clash.\nfunc mergedAccountLabels(explicit: [String], discovered: [String]) -> [String] {\n explicit + discovered.filter { !explicit.contains($0) }\n}\n\n/// All configured Claude account labels, from the same config the binary reads.\nfunc claudeAccountLabels() -> [String] {\n guard let text = try? String(contentsOfFile: configPathTOML(), encoding: .utf8) else {\n return []\n }\n let explicit = anthropicAccountLabels(inTOML: text)\n var discovered: [String] = []\n if let dir = tomlValueInText(text, section: \"anthropic\", key: \"accounts_dir\"), !dir.isEmpty {\n discovered = discoverAccountLabels(inDir: NSString(string: dir).expandingTildeInPath)\n }\n return mergedAccountLabels(explicit: explicit, discovered: discovered)\n}\n\n/// Rust semantics (`show_default_account`): `false` hides the default\n/// (unnamed) Claude entry, but is ignored when there are no named accounts,\n/// so Anthropic never loses its only entry.\nfunc showDefaultClaudeAccount(configValue: String?, hasAccounts: Bool) -> Bool {\n guard hasAccounts else { return true }\n return configValue != \"false\"\n}\n\n/// Pseudo-id mapping: `anthropic@<label>` selects a named account.\nlet ACCOUNT_ID_PREFIX = \"anthropic@\"\n\nfunc accountLabel(of id: String) -> String? {\n id.hasPrefix(ACCOUNT_ID_PREFIX) ? String(id.dropFirst(ACCOUNT_ID_PREFIX.count)) : nil\n}\n\nfunc baseVendorId(_ id: String) -> String {\n accountLabel(of: id) != nil ? \"anthropic\" : id\n}\n\n/// The subprocess arguments that select `id` (base vendor or named account).\nfunc vendorArgs(for id: String) -> [String] {\n if let label = accountLabel(of: id) {\n return [\"--vendor\", \"anthropic\", \"--account\", label]\n }\n return [\"--vendor\", id]\n}\n\n/// One selectable entry: a base vendor or one Claude account.\nstruct MenuEntry {\n let id: String // \"cursor\", \"anthropic\", or \"anthropic@<label>\"\n let name: String // display: \"Cursor\", \"Claude · <label>\"\n}\n\n/// Apply `[ui] overview_vendors` with the same semantics as the TUI: preserve\n/// config order, omit unavailable vendors, and include every named Claude\n/// account when `anthropic` is requested.\nfunc filterOverviewEntries(_ entries: [MenuEntry], requested: [String]?) -> [MenuEntry] {\n guard let requested else { return entries }\n var result: [MenuEntry] = []\n var seen = Set<String>()\n for vendor in requested {\n for entry in entries where baseVendorId(entry.id) == vendor && !seen.contains(entry.id) {\n result.append(entry)\n seen.insert(entry.id)\n }\n }\n return result\n}\n\n/// The selectable entries, in menu order: every enabled+configured vendor,\n/// with Anthropic expanded into its named accounts (the default entry kept\n/// only per `show_default_account`). `active` stays listed even when\n/// unconfigured — same rule the per-vendor list always had.\nfunc vendorEntries(active: String) -> [MenuEntry] {\n var out: [MenuEntry] = []\n for v in VENDOR_AUTH where vendorEnabled(v) {\n if v.id == \"anthropic\" {\n let labels = claudeAccountLabels()\n let showDefault = showDefaultClaudeAccount(\n configValue: configValueTOML(\"anthropic\", \"show_default_account\"),\n hasAccounts: !labels.isEmpty)\n if showDefault && (v.id == active || vendorConfigured(v)) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n for label in labels {\n out.append(MenuEntry(id: ACCOUNT_ID_PREFIX + label, name: \"Claude · \\(label)\"))\n }\n } else if v.id == active || vendorConfigured(v) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n }\n return out\n}\n\n/// Display name for any selectable id (base vendor, account, or overview).\nfunc entryDisplayName(_ id: String) -> String {\n if id == \"overview\" { return \"Visão geral\" }\n if let label = accountLabel(of: id) { return \"Claude · \\(label)\" }\n return VENDOR_AUTH.first { $0.id == id }?.name ?? id\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"compactShortcutEnabled\") private var compactShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n @State private var launchAtLogin = launchAgentIsInstalled()\n @State private var launchAtLoginError: String?\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker. Claude\n // accounts appear as their `anthropic@<label>` pseudo-ids, same as the\n // \"Trocar vendor\" submenu.\n private var vendors: [String] {\n var ids = VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n let labels = claudeAccountLabels()\n if let at = ids.firstIndex(of: \"anthropic\") {\n ids.insert(contentsOf: labels.map { ACCOUNT_ID_PREFIX + $0 }, at: at + 1)\n }\n return ids\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n Toggle(\"Compactar/Expandir com ⌥⌘E (atalho global)\", isOn: $compactShortcutEnabled)\n Text(\"Alterna a Visão geral entre barras e modo compacto.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Sistema\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Iniciar no login\", isOn: Binding(\n get: { launchAtLogin },\n set: { enabled in\n do {\n try setLaunchAtLogin(enabled)\n launchAtLogin = launchAgentIsInstalled()\n launchAtLoginError = nil\n } catch {\n launchAtLogin = launchAgentIsInstalled()\n launchAtLoginError = error.localizedDescription\n }\n }))\n Text(\"Instala um LaunchAgent que sobe o app ao entrar na sua conta.\")\n .font(.caption).foregroundColor(.secondary)\n if let error = launchAtLoginError {\n Text(\"Não foi possível atualizar: \\(error)\")\n .font(.caption).foregroundColor(.red)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n .onAppear { launchAtLogin = launchAgentIsInstalled() }\n }\n}\n\n// ─── Launch at login (macOS LaunchAgent) ─────────────────────────────────\n//\n// The app is an unbundled single binary, so the modern SMAppService.mainApp\n// API (which needs a bundle id) doesn't apply. A per-user LaunchAgent is the\n// portable way to start an unbundled executable at login — the same mechanism\n// install-agent.sh sets up, now toggleable from Preferences.\nlet LAUNCH_AGENT_LABEL = \"com.akitaonrails.ai-usagebar-menubar\"\n\nfunc launchAgentPlistPath() -> String {\n \"\\(NSHomeDirectory())/Library/LaunchAgents/\\(LAUNCH_AGENT_LABEL).plist\"\n}\n\nfunc launchAgentIsInstalled() -> Bool {\n FileManager.default.fileExists(atPath: launchAgentPlistPath())\n}\n\n/// Absolute path of the running executable, for the LaunchAgent to relaunch.\nfunc selfExecutablePath() -> String {\n if let p = Bundle.main.executablePath, !p.isEmpty { return p }\n let arg0 = CommandLine.arguments.first ?? \"ai-usagebar-menubar\"\n if arg0.hasPrefix(\"/\") { return arg0 }\n return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)\n .appendingPathComponent(arg0).standardized.path\n}\n\n/// Install (or remove) the login LaunchAgent by managing its plist file.\n/// Building the plist via PropertyListSerialization sidesteps the XML-escaping\n/// the shell installer has to do by hand.\n///\n/// Deliberately no `launchctl load`/`unload`: the app is already running when\n/// this is toggled, so `RunAtLoad` on load would spawn a *second* copy, and\n/// `unload` on disable would SIGTERM this very process (quitting the app the\n/// user is still using) if it happened to be the launchd-managed one. launchd\n/// loads every agent in ~/Library/LaunchAgents at the next login, so writing\n/// (or removing) the file is all that's needed for a start-at-login toggle.\nfunc launchAgentPlist(executable: String) throws -> Data {\n let plist: [String: Any] = [\n \"Label\": LAUNCH_AGENT_LABEL,\n \"ProgramArguments\": [executable],\n \"RunAtLoad\": true,\n \"ProcessType\": \"Interactive\",\n ]\n return try PropertyListSerialization.data(\n fromPropertyList: plist, format: .xml, options: 0)\n}\n\nfunc setLaunchAtLogin(_ enabled: Bool) throws {\n let path = launchAgentPlistPath()\n if enabled {\n let dir = (path as NSString).deletingLastPathComponent\n try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)\n let data = try launchAgentPlist(executable: selfExecutablePath())\n try data.write(to: URL(fileURLWithPath: path), options: .atomic)\n } else if FileManager.default.fileExists(atPath: path) {\n try FileManager.default.removeItem(atPath: path)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\nlet compactHotKeyNotification = Notification.Name(\"aiusagebar.compactHotKey\")\n\n/// Default shortcuts: `\\` (kVK_ANSI_Backslash) with Command+Option swaps the\n/// vendor; `E` with Command+Option toggles the overview's Compactar/Expandir.\n/// `[ui]` on Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet COMPACT_HOTKEY_KEYCODE = UInt32(kVK_ANSI_E)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\n/// Registered hot-key ids, carried back to us in the event so one shared C\n/// callback can tell which shortcut fired.\nlet SWAP_HOTKEY_ID = UInt32(1)\nlet COMPACT_HOTKEY_ID = UInt32(2)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n var hk = EventHotKeyID()\n GetEventParameter(event, EventParamName(kEventParamDirectObject),\n EventParamType(typeEventHotKeyID), nil,\n MemoryLayout<EventHotKeyID>.size, nil, &hk)\n NotificationCenter.default.post(\n name: hk.id == COMPACT_HOTKEY_ID ? compactHotKeyNotification : swapHotKeyNotification,\n object: nil)\n return noErr\n}\n\n/// Whether the overview status-bar title draws mini bars (vs. the compact\n/// %-text mode). \"Compactar\" forces the text mode even under the bars-count\n/// threshold. Pure + testable — the render path is not.\nfunc overviewUsesBars(count: Int, barsMax: Int, compact: Bool) -> Bool {\n !compact && count <= barsMax\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nfunc hotKeyRegistrationSucceeded(_ status: OSStatus) -> Bool {\n status == noErr\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var compactHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Live watch on config.toml so external edits (a text editor, the TUI's\n /// Settings overlay, `ai-usagebar --add-custom-claude-account`) hot-reload\n /// the vendor list without restarting the app. The fd is closed by the\n /// source's cancel handler; `pendingConfigReload` coalesces an editor's\n /// burst of vnode events into a single reload.\n var configWatchSource: DispatchSourceFileSystemObject?\n var configWatchFD: CInt = -1\n var pendingConfigReload: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor/account); hidden in\n // single-vendor mode. The pool starts at the built-in vendor count and can\n // grow if account discovery adds more rows.\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n /// Overview-only: forces the status-bar title into the compact %-text mode\n /// (\"Compactar\"); while compact it reads \"Expandir\" and turns it back off.\n let compactItem = NSMenuItem(title: \"Compactar\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true, \"compactShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleCompactHotKey),\n name: compactHotKeyNotification, object: nil)\n installSwapHotKey()\n installCompactHotKey()\n installConfigWatch()\n }\n\n /// Watch config.toml and hot-reload the vendor list when it changes on disk.\n /// Editors usually save atomically (write a temp file, then rename it over\n /// the target), which unlinks the inode our descriptor points at — so a\n /// `.delete`/`.rename` event means \"re-open the path to keep watching the\n /// new file\". A plain in-place write (`>>`, some editors) fires `.write` on\n /// the same descriptor. Idempotent: always tears down the previous watch.\n func installConfigWatch() {\n removeConfigWatch()\n let path = configPathTOML()\n let fd = open(path, O_EVTONLY)\n guard fd >= 0 else {\n // No readable file yet (not created, or momentarily gone between an\n // editor's unlink and rename). Retry shortly so a config created\n // after launch — or a non-atomic save's brief gap — is still picked\n // up. ponytail: a fixed 3s retry, not exponential backoff; this only\n // spins in the rare window before the file exists.\n DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in\n self?.installConfigWatch()\n }\n return\n }\n let src = DispatchSource.makeFileSystemObjectSource(\n fileDescriptor: fd, eventMask: [.write, .extend, .delete, .rename, .attrib],\n queue: .main)\n src.setEventHandler { [weak self] in\n guard let self, let flags = self.configWatchSource?.data else { return }\n self.scheduleConfigReload()\n // The watched inode was replaced; re-arm on whatever is at the path\n // now so future edits keep firing.\n if flags.contains(.delete) || flags.contains(.rename) {\n self.installConfigWatch()\n }\n }\n src.setCancelHandler { close(fd) }\n configWatchFD = fd\n configWatchSource = src\n src.resume()\n }\n\n func removeConfigWatch() {\n configWatchSource?.cancel() // the cancel handler closes the fd\n configWatchSource = nil\n configWatchFD = -1\n }\n\n /// Coalesce the burst of vnode events a save emits (write → rename → attrib)\n /// into one reload a beat later, mirroring `settingsChanged`'s debounce.\n func scheduleConfigReload() {\n pendingConfigReload?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.configFileChanged() }\n pendingConfigReload = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)\n }\n\n /// A config.toml edit landed: rebuild the vendor submenu/ring, re-render the\n /// current view from cache so an `[ui]` tweak shows at once, then debounce a\n /// full refresh to fetch any newly-added vendor/account. Reads are all fresh\n /// from disk (nothing caches config.toml), so no invalidation is needed.\n @objc func configFileChanged() {\n rebuildVendorSubmenu()\n if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n guard removeSwapHotKey() else {\n // The old registration is still live; reflect that instead of\n // showing a disabled toggle that continues intercepting the key.\n DEF.set(true, forKey: \"swapShortcutEnabled\")\n return\n }\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n guard installHotKeyHandlerOnce() else {\n disableFailedShortcut(\"swapShortcutEnabled\", name: \"⌥⌘\\\\\")\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n return\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: SWAP_HOTKEY_ID) // 'AIBB'\n var ref: EventHotKeyRef?\n let status = RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &ref)\n guard hotKeyRegistrationSucceeded(status), let ref else {\n if let ref { UnregisterEventHotKey(ref) }\n disableFailedShortcut(\"swapShortcutEnabled\", name: \"⌥⌘\\\\\", status: status)\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n return\n }\n swapHotKeyRef = ref\n }\n\n /// (Re)register the global ⌥⌘E Compactar/Expandir hot key to match the\n /// `compactShortcutEnabled` preference. Idempotent, mirroring\n /// `installSwapHotKey` — including the painted-in hint (a native\n /// keyEquivalent would fire a second time while the menu is open, double-\n /// toggling on one press).\n func installCompactHotKey() {\n guard removeCompactHotKey() else {\n DEF.set(true, forKey: \"compactShortcutEnabled\")\n return\n }\n updateCompactItemTitle()\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else { return }\n guard installHotKeyHandlerOnce() else {\n disableFailedShortcut(\"compactShortcutEnabled\", name: \"⌥⌘E\")\n updateCompactItemTitle()\n return\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: COMPACT_HOTKEY_ID)\n var ref: EventHotKeyRef?\n let status = RegisterEventHotKey(\n COMPACT_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &ref)\n guard hotKeyRegistrationSucceeded(status), let ref else {\n if let ref { UnregisterEventHotKey(ref) }\n disableFailedShortcut(\"compactShortcutEnabled\", name: \"⌥⌘E\", status: status)\n updateCompactItemTitle()\n return\n }\n compactHotKeyRef = ref\n }\n\n /// Compactar ↔ Expandir label plus the dimmed ⌥⌘E hint when the global\n /// shortcut is on. Shared by the overview render and the hot-key installer.\n func updateCompactItemTitle() {\n let label = DEF.bool(forKey: \"overviewCompact\") ? \"Expandir\" : \"Compactar\"\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else {\n compactItem.attributedTitle = NSAttributedString(string: label)\n return\n }\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(string: \"\\(label) \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘E\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n compactItem.attributedTitle = s\n }\n\n /// One Carbon handler serves every hot key; the event's EventHotKeyID\n /// says which one fired.\n private func installHotKeyHandlerOnce() -> Bool {\n guard !swapHotKeyHandlerInstalled else { return true }\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n let status = InstallEventHandler(\n GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: global hot-key handler registration failed (OSStatus %d)\", status)\n return false\n }\n swapHotKeyHandlerInstalled = true\n return true\n }\n\n private func disableFailedShortcut(_ key: String, name: String,\n status: OSStatus? = nil) {\n if let status {\n NSLog(\"ai-usagebar: global shortcut %@ registration failed (OSStatus %d)\",\n name, status)\n } else {\n NSLog(\"ai-usagebar: global shortcut %@ registration failed\", name)\n }\n // Keep the visible preference honest when another app already owns the\n // shortcut or Carbon refuses the registration.\n DEF.set(false, forKey: key)\n }\n\n @discardableResult\n func removeSwapHotKey() -> Bool {\n if let ref = swapHotKeyRef {\n let status = UnregisterEventHotKey(ref)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: could not unregister ⌥⌘\\\\ (OSStatus %d)\", status)\n return false\n }\n swapHotKeyRef = nil\n }\n return true\n }\n\n @discardableResult\n private func removeCompactHotKey() -> Bool {\n if let ref = compactHotKeyRef {\n let status = UnregisterEventHotKey(ref)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: could not unregister ⌥⌘E (OSStatus %d)\", status)\n return false\n }\n compactHotKeyRef = nil\n }\n return true\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// ⌥⌘E: toggle Compactar/Expandir. Overview-only — flipping a hidden\n /// preference from another view would be invisible and confusing.\n @objc func handleCompactHotKey() {\n guard VENDOR == \"overview\" else { return }\n toggleCompact()\n }\n\n /// The swap-shortcut ring: every configured entry (vendors *and* Claude\n /// accounts) plus the synthetic \"overview\" target, so ⌥⌘\\ cycles them all.\n func swapCycleIds() -> [String] {\n var ids = vendorEntries(active: VENDOR).map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per built-in vendor for Overview mode. Named accounts\n // can take the total higher; renderOverview grows the pool on demand.\n for _ in 0..<VENDOR_AUTH.count {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n // First item after the usage rows, Overview-only (hidden elsewhere).\n compactItem.action = #selector(toggleCompact)\n compactItem.target = self\n compactItem.isHidden = true\n menu.addItem(compactItem)\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n /// Flip the overview's compact mode; the UserDefaults observer re-renders,\n /// which also relabels the item (Compactar ↔ Expandir).\n @objc func toggleCompact() {\n DEF.set(!DEF.bool(forKey: \"overviewCompact\"), forKey: \"overviewCompact\")\n }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n if DEF.bool(forKey: \"compactShortcutEnabled\") != (compactHotKeyRef != nil) {\n installCompactHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = entryDisplayName(VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: vendor) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n // vendorEntries(active: \"\") keeps only configured entries — the\n // active-vendor courtesy slot has no place in an all-vendors sweep.\n let entries = filterOverviewEntries(vendorEntries(active: \"\"),\n requested: configuredOverviewVendorIds())\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in entries {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: v.id) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: baseVendorId(v.id))\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// Compact bar label for a vendor name. Usually the first word; for a\n /// Claude account (\"Claude · gmail\") the account label is the part that\n /// distinguishes entries, so use it instead of the shared \"Claude\".\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let base = name.range(of: \" · \").map { String(name[$0.upperBound...]) }\n ?? String(name.split(separator: \" \").first ?? Substring(name))\n return base.count <= maxLen ? base : String(base.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 4) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String, reset: String?)] =\n items.compactMap {\n guard let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\", nil) }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\", h.reset.flatMap(shortReset))\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 4\n let compact = DEF.bool(forKey: \"overviewCompact\")\n let t = NSMutableAttributedString()\n if overviewUsesBars(count: heads.count, barsMax: barsMax, compact: compact) {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n // Entry order, not worst-first: entries are provider-grouped\n // (standard Claude, then its accounts, then the other vendors), and\n // stable positions beat a pct sort that reshuffles labels on every\n // refresh.\n for (i, e) in heads.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n }\n if heads.count > maxN { t.append(run(\" +\\(heads.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n ensureOverviewRowCapacity(items.count)\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to jump straight to that vendor's detail view.\n it.representedObject = item.id\n it.target = self\n it.action = #selector(switchVendor(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n compactItem.isHidden = false\n updateCompactItemTitle()\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n /// Add overview row slots before the single-vendor rows when newly\n /// discovered Claude accounts outgrow the initial built-in-vendor pool.\n private func ensureOverviewRowCapacity(_ count: Int) {\n guard count > overviewRows.count,\n let menu = statusItem.menu,\n let firstDetail = rows[\"session\"] else { return }\n while overviewRows.count < count {\n let item = NSMenuItem()\n item.isHidden = true\n menu.insertItem(item, at: menu.index(of: firstDetail))\n overviewRows.append(item)\n }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: baseVendorId(VENDOR)) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n // With several Claude accounts the plan alone (\"Claude Max 20x\") no\n // longer says WHICH account this is — suffix the label.\n let plan = s.plan.isEmpty ? \"AI Usage\" : s.plan\n let header = accountLabel(of: VENDOR).map { \"\\(plan) · \\($0)\" } ?? plan\n headerItem.attributedTitle = run(header, .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let entries = vendorEntries(active: active)\n if entries.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in entries {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "9239b68aaed3c53b294fa063ab96d98bd4932329": {"t": 1785750125.4571717, "n": 99262, "h": "b85eb80b94f7982b", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\n/// Squeeze a countdown (\"4d 1h\", \"2h 05m\", \"now\") down to its leading unit for\n/// the overview status-bar title, where every character fights for space:\n/// \"4d 1h\" → \"4d\", \"2h 05m\" → \"2h\", \"0h 05m\" → \"5m\". Missing/em-dash → nil.\nfunc shortReset(_ r: String) -> String? {\n guard !r.isEmpty, r != \"—\" else { return nil }\n let parts = r.split(separator: \" \")\n guard let first = parts.first else { return nil }\n if first == \"0h\", parts.count > 1 {\n let m = parts[1].drop(while: { $0 == \"0\" })\n return m == \"m\" ? \"0m\" : String(m)\n }\n return String(first)\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\n/// Read a TOML array of quoted strings. This intentionally stays small (the\n/// native app is a single dependency-free Swift file), but accepts multiline\n/// arrays and rejects malformed values instead of silently widening Overview.\nfunc tomlStringArrayInText(_ text: String, section: String, key: String) -> [String]? {\n func withoutComment(_ line: String) -> String {\n var result = \"\"\n var quote: Character?\n var escaped = false\n for ch in line {\n if let active = quote {\n result.append(ch)\n if active == \"\\\"\", escaped {\n escaped = false\n } else if active == \"\\\"\", ch == \"\\\\\" {\n escaped = true\n } else if ch == active {\n quote = nil\n }\n } else if ch == \"#\" {\n break\n } else {\n result.append(ch)\n if ch == \"\\\"\" || ch == \"'\" { quote = ch }\n }\n }\n return result\n }\n\n func hasClosingBracket(_ value: String) -> Bool {\n var quote: Character?\n var escaped = false\n for ch in value {\n if let active = quote {\n if active == \"\\\"\", escaped {\n escaped = false\n } else if active == \"\\\"\", ch == \"\\\\\" {\n escaped = true\n } else if ch == active {\n quote = nil\n }\n } else if ch == \"\\\"\" || ch == \"'\" {\n quote = ch\n } else if ch == \"]\" {\n return true\n }\n }\n return false\n }\n\n var inSection = false\n var rawValue: String?\n for rawLine in text.components(separatedBy: .newlines) {\n let line = withoutComment(rawLine).trimmingCharacters(in: .whitespaces)\n if let current = rawValue {\n let joined = current + \" \" + line\n rawValue = joined\n if hasClosingBracket(joined) { break }\n continue\n }\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.isEmpty else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n guard value.hasPrefix(\"[\") else { return nil }\n rawValue = value\n if hasClosingBracket(value) { break }\n }\n\n guard let raw = rawValue, hasClosingBracket(raw) else { return nil }\n let chars = Array(raw)\n guard chars.first == \"[\" else { return nil }\n var result: [String] = []\n var i = 1\n\n func skipSpace() {\n while i < chars.count && chars[i].isWhitespace { i += 1 }\n }\n\n while true {\n skipSpace()\n guard i < chars.count else { return nil }\n if chars[i] == \"]\" {\n i += 1\n skipSpace()\n return i == chars.count ? result : nil\n }\n let quote = chars[i]\n guard quote == \"\\\"\" || quote == \"'\" else { return nil }\n i += 1\n var value = \"\"\n var closed = false\n while i < chars.count {\n let ch = chars[i]\n i += 1\n if ch == quote {\n closed = true\n break\n }\n if quote == \"\\\"\", ch == \"\\\\\", i < chars.count {\n value.append(chars[i])\n i += 1\n } else {\n value.append(ch)\n }\n }\n guard closed else { return nil }\n result.append(value)\n skipSpace()\n guard i < chars.count else { return nil }\n if chars[i] == \",\" {\n i += 1\n continue\n }\n guard chars[i] == \"]\" else { return nil }\n }\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc configuredOverviewVendorIds() -> [String]? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlStringArrayInText(text, section: \"ui\", key: \"overview_vendors\")\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n// ── Anthropic multi-account ────────────────────────────────────────────────\n// Mirrors the Rust side (`src/config.rs`): explicit `[[anthropic.accounts]]`\n// entries plus subdirectories of `[anthropic] accounts_dir`, explicit labels\n// winning on a clash. Each account is a\n// selectable entry with the pseudo-id `anthropic@<label>`, fetched by the\n// binary as `--vendor anthropic --account <label>` — the binary resolves the\n// account's credentials itself (file or its own Keychain item), so the app\n// never needs to know where they live.\n\n/// `[[anthropic.accounts]]` labels, in file order. Pure text scan, like the\n/// rest of this app's TOML reading: a `[[anthropic.accounts]]` header opens a\n/// block, the first `label = \"…\"` inside it counts.\nfunc anthropicAccountLabels(inTOML text: String) -> [String] {\n var labels: [String] = []\n var inBlock = false\n for rawLine in text.components(separatedBy: .newlines) {\n let line = rawLine.trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inBlock = line == \"[[anthropic.accounts]]\"\n continue\n }\n guard inBlock, line.hasPrefix(\"label\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == \"label\" else { continue }\n var value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n value = String(content[..<end])\n }\n if !value.isEmpty {\n labels.append(value)\n inBlock = false // one label per block\n }\n }\n return labels\n}\n\n/// Immediate subdirectories of `dir`, sorted — the same discovery rule as the\n/// Rust `accounts_dir` scan. Credentials may be file- or Keychain-backed.\nfunc discoverAccountLabels(inDir dir: String) -> [String] {\n let fm = FileManager.default\n guard let names = try? fm.contentsOfDirectory(atPath: dir) else { return [] }\n return names.filter { name in\n var isDir: ObjCBool = false\n let sub = \"\\(dir)/\\(name)\"\n return fm.fileExists(atPath: sub, isDirectory: &isDir) && isDir.boolValue\n }.sorted()\n}\n\n/// Explicit labels first (file order), then discovered ones that don't clash.\nfunc mergedAccountLabels(explicit: [String], discovered: [String]) -> [String] {\n explicit + discovered.filter { !explicit.contains($0) }\n}\n\n/// All configured Claude account labels, from the same config the binary reads.\nfunc claudeAccountLabels() -> [String] {\n guard let text = try? String(contentsOfFile: configPathTOML(), encoding: .utf8) else {\n return []\n }\n let explicit = anthropicAccountLabels(inTOML: text)\n var discovered: [String] = []\n if let dir = tomlValueInText(text, section: \"anthropic\", key: \"accounts_dir\"), !dir.isEmpty {\n discovered = discoverAccountLabels(inDir: NSString(string: dir).expandingTildeInPath)\n }\n return mergedAccountLabels(explicit: explicit, discovered: discovered)\n}\n\n/// Rust semantics (`show_default_account`): `false` hides the default\n/// (unnamed) Claude entry, but is ignored when there are no named accounts,\n/// so Anthropic never loses its only entry.\nfunc showDefaultClaudeAccount(configValue: String?, hasAccounts: Bool) -> Bool {\n guard hasAccounts else { return true }\n return configValue != \"false\"\n}\n\n/// Pseudo-id mapping: `anthropic@<label>` selects a named account.\nlet ACCOUNT_ID_PREFIX = \"anthropic@\"\n\nfunc accountLabel(of id: String) -> String? {\n id.hasPrefix(ACCOUNT_ID_PREFIX) ? String(id.dropFirst(ACCOUNT_ID_PREFIX.count)) : nil\n}\n\nfunc baseVendorId(_ id: String) -> String {\n accountLabel(of: id) != nil ? \"anthropic\" : id\n}\n\n/// The subprocess arguments that select `id` (base vendor or named account).\nfunc vendorArgs(for id: String) -> [String] {\n if let label = accountLabel(of: id) {\n return [\"--vendor\", \"anthropic\", \"--account\", label]\n }\n return [\"--vendor\", id]\n}\n\n/// One selectable entry: a base vendor or one Claude account.\nstruct MenuEntry {\n let id: String // \"cursor\", \"anthropic\", or \"anthropic@<label>\"\n let name: String // display: \"Cursor\", \"Claude · <label>\"\n}\n\n/// Apply `[ui] overview_vendors` with the same semantics as the TUI: preserve\n/// config order, omit unavailable vendors, and include every named Claude\n/// account when `anthropic` is requested.\nfunc filterOverviewEntries(_ entries: [MenuEntry], requested: [String]?) -> [MenuEntry] {\n guard let requested else { return entries }\n var result: [MenuEntry] = []\n var seen = Set<String>()\n for vendor in requested {\n for entry in entries where baseVendorId(entry.id) == vendor && !seen.contains(entry.id) {\n result.append(entry)\n seen.insert(entry.id)\n }\n }\n return result\n}\n\n/// The selectable entries, in menu order: every enabled+configured vendor,\n/// with Anthropic expanded into its named accounts (the default entry kept\n/// only per `show_default_account`). `active` stays listed even when\n/// unconfigured — same rule the per-vendor list always had.\nfunc vendorEntries(active: String) -> [MenuEntry] {\n var out: [MenuEntry] = []\n for v in VENDOR_AUTH where vendorEnabled(v) {\n if v.id == \"anthropic\" {\n let labels = claudeAccountLabels()\n let showDefault = showDefaultClaudeAccount(\n configValue: configValueTOML(\"anthropic\", \"show_default_account\"),\n hasAccounts: !labels.isEmpty)\n if showDefault && (v.id == active || vendorConfigured(v)) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n for label in labels {\n out.append(MenuEntry(id: ACCOUNT_ID_PREFIX + label, name: \"Claude · \\(label)\"))\n }\n } else if v.id == active || vendorConfigured(v) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n }\n return out\n}\n\n/// Display name for any selectable id (base vendor, account, or overview).\nfunc entryDisplayName(_ id: String) -> String {\n if id == \"overview\" { return \"Visão geral\" }\n if let label = accountLabel(of: id) { return \"Claude · \\(label)\" }\n return VENDOR_AUTH.first { $0.id == id }?.name ?? id\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"compactShortcutEnabled\") private var compactShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n @State private var launchAtLogin = launchAgentIsInstalled()\n @State private var launchAtLoginError: String?\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker. Claude\n // accounts appear as their `anthropic@<label>` pseudo-ids, same as the\n // \"Trocar vendor\" submenu.\n private var vendors: [String] {\n var ids = VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n let labels = claudeAccountLabels()\n if let at = ids.firstIndex(of: \"anthropic\") {\n ids.insert(contentsOf: labels.map { ACCOUNT_ID_PREFIX + $0 }, at: at + 1)\n }\n return ids\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n Toggle(\"Compactar/Expandir com ⌥⌘E (atalho global)\", isOn: $compactShortcutEnabled)\n Text(\"Alterna a Visão geral entre barras e modo compacto.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Sistema\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Iniciar no login\", isOn: Binding(\n get: { launchAtLogin },\n set: { enabled in\n do {\n try setLaunchAtLogin(enabled)\n launchAtLogin = launchAgentIsInstalled()\n launchAtLoginError = nil\n } catch {\n launchAtLogin = launchAgentIsInstalled()\n launchAtLoginError = error.localizedDescription\n }\n }))\n Text(\"Instala um LaunchAgent que sobe o app ao entrar na sua conta.\")\n .font(.caption).foregroundColor(.secondary)\n if let error = launchAtLoginError {\n Text(\"Não foi possível atualizar: \\(error)\")\n .font(.caption).foregroundColor(.red)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n .onAppear { launchAtLogin = launchAgentIsInstalled() }\n }\n}\n\n// ─── Launch at login (macOS LaunchAgent) ─────────────────────────────────\n//\n// The app is an unbundled single binary, so the modern SMAppService.mainApp\n// API (which needs a bundle id) doesn't apply. A per-user LaunchAgent is the\n// portable way to start an unbundled executable at login — the same mechanism\n// install-agent.sh sets up, now toggleable from Preferences.\nlet LAUNCH_AGENT_LABEL = \"com.akitaonrails.ai-usagebar-menubar\"\n\nfunc launchAgentPlistPath() -> String {\n \"\\(NSHomeDirectory())/Library/LaunchAgents/\\(LAUNCH_AGENT_LABEL).plist\"\n}\n\nfunc launchAgentIsInstalled() -> Bool {\n FileManager.default.fileExists(atPath: launchAgentPlistPath())\n}\n\n/// Absolute path of the running executable, for the LaunchAgent to relaunch.\nfunc selfExecutablePath() -> String {\n if let p = Bundle.main.executablePath, !p.isEmpty { return p }\n let arg0 = CommandLine.arguments.first ?? \"ai-usagebar-menubar\"\n if arg0.hasPrefix(\"/\") { return arg0 }\n return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)\n .appendingPathComponent(arg0).standardized.path\n}\n\n/// Install (or remove) the login LaunchAgent by managing its plist file.\n/// Building the plist via PropertyListSerialization sidesteps the XML-escaping\n/// the shell installer has to do by hand.\n///\n/// Deliberately no `launchctl load`/`unload`: the app is already running when\n/// this is toggled, so `RunAtLoad` on load would spawn a *second* copy, and\n/// `unload` on disable would SIGTERM this very process (quitting the app the\n/// user is still using) if it happened to be the launchd-managed one. launchd\n/// loads every agent in ~/Library/LaunchAgents at the next login, so writing\n/// (or removing) the file is all that's needed for a start-at-login toggle.\nfunc launchAgentPlist(executable: String) throws -> Data {\n let plist: [String: Any] = [\n \"Label\": LAUNCH_AGENT_LABEL,\n \"ProgramArguments\": [executable],\n \"RunAtLoad\": true,\n \"ProcessType\": \"Interactive\",\n ]\n return try PropertyListSerialization.data(\n fromPropertyList: plist, format: .xml, options: 0)\n}\n\nfunc setLaunchAtLogin(_ enabled: Bool) throws {\n let path = launchAgentPlistPath()\n if enabled {\n let dir = (path as NSString).deletingLastPathComponent\n try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)\n let data = try launchAgentPlist(executable: selfExecutablePath())\n try data.write(to: URL(fileURLWithPath: path), options: .atomic)\n } else if FileManager.default.fileExists(atPath: path) {\n try FileManager.default.removeItem(atPath: path)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\nlet compactHotKeyNotification = Notification.Name(\"aiusagebar.compactHotKey\")\n\n/// Default shortcuts: `\\` (kVK_ANSI_Backslash) with Command+Option swaps the\n/// vendor; `E` with Command+Option toggles the overview's Compactar/Expandir.\n/// `[ui]` on Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet COMPACT_HOTKEY_KEYCODE = UInt32(kVK_ANSI_E)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\n/// Registered hot-key ids, carried back to us in the event so one shared C\n/// callback can tell which shortcut fired.\nlet SWAP_HOTKEY_ID = UInt32(1)\nlet COMPACT_HOTKEY_ID = UInt32(2)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n var hk = EventHotKeyID()\n GetEventParameter(event, EventParamName(kEventParamDirectObject),\n EventParamType(typeEventHotKeyID), nil,\n MemoryLayout<EventHotKeyID>.size, nil, &hk)\n NotificationCenter.default.post(\n name: hk.id == COMPACT_HOTKEY_ID ? compactHotKeyNotification : swapHotKeyNotification,\n object: nil)\n return noErr\n}\n\n/// Whether the overview status-bar title draws mini bars (vs. the compact\n/// %-text mode). \"Compactar\" forces the text mode even under the bars-count\n/// threshold. Pure + testable — the render path is not.\nfunc overviewUsesBars(count: Int, barsMax: Int, compact: Bool) -> Bool {\n !compact && count <= barsMax\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nfunc hotKeyRegistrationSucceeded(_ status: OSStatus) -> Bool {\n status == noErr\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var compactHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor/account); hidden in\n // single-vendor mode. The pool starts at the built-in vendor count and can\n // grow if account discovery adds more rows.\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n /// Overview-only: forces the status-bar title into the compact %-text mode\n /// (\"Compactar\"); while compact it reads \"Expandir\" and turns it back off.\n let compactItem = NSMenuItem(title: \"Compactar\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true, \"compactShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleCompactHotKey),\n name: compactHotKeyNotification, object: nil)\n installSwapHotKey()\n installCompactHotKey()\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n guard removeSwapHotKey() else {\n // The old registration is still live; reflect that instead of\n // showing a disabled toggle that continues intercepting the key.\n DEF.set(true, forKey: \"swapShortcutEnabled\")\n return\n }\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n guard installHotKeyHandlerOnce() else {\n disableFailedShortcut(\"swapShortcutEnabled\", name: \"⌥⌘\\\\\")\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n return\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: SWAP_HOTKEY_ID) // 'AIBB'\n var ref: EventHotKeyRef?\n let status = RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &ref)\n guard hotKeyRegistrationSucceeded(status), let ref else {\n if let ref { UnregisterEventHotKey(ref) }\n disableFailedShortcut(\"swapShortcutEnabled\", name: \"⌥⌘\\\\\", status: status)\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n return\n }\n swapHotKeyRef = ref\n }\n\n /// (Re)register the global ⌥⌘E Compactar/Expandir hot key to match the\n /// `compactShortcutEnabled` preference. Idempotent, mirroring\n /// `installSwapHotKey` — including the painted-in hint (a native\n /// keyEquivalent would fire a second time while the menu is open, double-\n /// toggling on one press).\n func installCompactHotKey() {\n guard removeCompactHotKey() else {\n DEF.set(true, forKey: \"compactShortcutEnabled\")\n return\n }\n updateCompactItemTitle()\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else { return }\n guard installHotKeyHandlerOnce() else {\n disableFailedShortcut(\"compactShortcutEnabled\", name: \"⌥⌘E\")\n updateCompactItemTitle()\n return\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: COMPACT_HOTKEY_ID)\n var ref: EventHotKeyRef?\n let status = RegisterEventHotKey(\n COMPACT_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &ref)\n guard hotKeyRegistrationSucceeded(status), let ref else {\n if let ref { UnregisterEventHotKey(ref) }\n disableFailedShortcut(\"compactShortcutEnabled\", name: \"⌥⌘E\", status: status)\n updateCompactItemTitle()\n return\n }\n compactHotKeyRef = ref\n }\n\n /// Compactar ↔ Expandir label plus the dimmed ⌥⌘E hint when the global\n /// shortcut is on. Shared by the overview render and the hot-key installer.\n func updateCompactItemTitle() {\n let label = DEF.bool(forKey: \"overviewCompact\") ? \"Expandir\" : \"Compactar\"\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else {\n compactItem.attributedTitle = NSAttributedString(string: label)\n return\n }\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(string: \"\\(label) \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘E\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n compactItem.attributedTitle = s\n }\n\n /// One Carbon handler serves every hot key; the event's EventHotKeyID\n /// says which one fired.\n private func installHotKeyHandlerOnce() -> Bool {\n guard !swapHotKeyHandlerInstalled else { return true }\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n let status = InstallEventHandler(\n GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: global hot-key handler registration failed (OSStatus %d)\", status)\n return false\n }\n swapHotKeyHandlerInstalled = true\n return true\n }\n\n private func disableFailedShortcut(_ key: String, name: String,\n status: OSStatus? = nil) {\n if let status {\n NSLog(\"ai-usagebar: global shortcut %@ registration failed (OSStatus %d)\",\n name, status)\n } else {\n NSLog(\"ai-usagebar: global shortcut %@ registration failed\", name)\n }\n // Keep the visible preference honest when another app already owns the\n // shortcut or Carbon refuses the registration.\n DEF.set(false, forKey: key)\n }\n\n @discardableResult\n func removeSwapHotKey() -> Bool {\n if let ref = swapHotKeyRef {\n let status = UnregisterEventHotKey(ref)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: could not unregister ⌥⌘\\\\ (OSStatus %d)\", status)\n return false\n }\n swapHotKeyRef = nil\n }\n return true\n }\n\n @discardableResult\n private func removeCompactHotKey() -> Bool {\n if let ref = compactHotKeyRef {\n let status = UnregisterEventHotKey(ref)\n guard hotKeyRegistrationSucceeded(status) else {\n NSLog(\"ai-usagebar: could not unregister ⌥⌘E (OSStatus %d)\", status)\n return false\n }\n compactHotKeyRef = nil\n }\n return true\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// ⌥⌘E: toggle Compactar/Expandir. Overview-only — flipping a hidden\n /// preference from another view would be invisible and confusing.\n @objc func handleCompactHotKey() {\n guard VENDOR == \"overview\" else { return }\n toggleCompact()\n }\n\n /// The swap-shortcut ring: every configured entry (vendors *and* Claude\n /// accounts) plus the synthetic \"overview\" target, so ⌥⌘\\ cycles them all.\n func swapCycleIds() -> [String] {\n var ids = vendorEntries(active: VENDOR).map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per built-in vendor for Overview mode. Named accounts\n // can take the total higher; renderOverview grows the pool on demand.\n for _ in 0..<VENDOR_AUTH.count {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n // First item after the usage rows, Overview-only (hidden elsewhere).\n compactItem.action = #selector(toggleCompact)\n compactItem.target = self\n compactItem.isHidden = true\n menu.addItem(compactItem)\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n /// Flip the overview's compact mode; the UserDefaults observer re-renders,\n /// which also relabels the item (Compactar ↔ Expandir).\n @objc func toggleCompact() {\n DEF.set(!DEF.bool(forKey: \"overviewCompact\"), forKey: \"overviewCompact\")\n }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n if DEF.bool(forKey: \"compactShortcutEnabled\") != (compactHotKeyRef != nil) {\n installCompactHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = entryDisplayName(VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: vendor) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n // vendorEntries(active: \"\") keeps only configured entries — the\n // active-vendor courtesy slot has no place in an all-vendors sweep.\n let entries = filterOverviewEntries(vendorEntries(active: \"\"),\n requested: configuredOverviewVendorIds())\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in entries {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: v.id) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: baseVendorId(v.id))\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// Compact bar label for a vendor name. Usually the first word; for a\n /// Claude account (\"Claude · gmail\") the account label is the part that\n /// distinguishes entries, so use it instead of the shared \"Claude\".\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let base = name.range(of: \" · \").map { String(name[$0.upperBound...]) }\n ?? String(name.split(separator: \" \").first ?? Substring(name))\n return base.count <= maxLen ? base : String(base.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 4) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String, reset: String?)] =\n items.compactMap {\n guard let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\", nil) }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\", h.reset.flatMap(shortReset))\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 4\n let compact = DEF.bool(forKey: \"overviewCompact\")\n let t = NSMutableAttributedString()\n if overviewUsesBars(count: heads.count, barsMax: barsMax, compact: compact) {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n // Entry order, not worst-first: entries are provider-grouped\n // (standard Claude, then its accounts, then the other vendors), and\n // stable positions beat a pct sort that reshuffles labels on every\n // refresh.\n for (i, e) in heads.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n }\n if heads.count > maxN { t.append(run(\" +\\(heads.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n ensureOverviewRowCapacity(items.count)\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to jump straight to that vendor's detail view.\n it.representedObject = item.id\n it.target = self\n it.action = #selector(switchVendor(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n compactItem.isHidden = false\n updateCompactItemTitle()\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n /// Add overview row slots before the single-vendor rows when newly\n /// discovered Claude accounts outgrow the initial built-in-vendor pool.\n private func ensureOverviewRowCapacity(_ count: Int) {\n guard count > overviewRows.count,\n let menu = statusItem.menu,\n let firstDetail = rows[\"session\"] else { return }\n while overviewRows.count < count {\n let item = NSMenuItem()\n item.isHidden = true\n menu.insertItem(item, at: menu.index(of: firstDetail))\n overviewRows.append(item)\n }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: baseVendorId(VENDOR)) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n // With several Claude accounts the plan alone (\"Claude Max 20x\") no\n // longer says WHICH account this is — suffix the label.\n let plan = s.plan.isEmpty ? \"AI Usage\" : s.plan\n let header = accountLabel(of: VENDOR).map { \"\\(plan) · \\($0)\" } ?? plan\n headerItem.attributedTitle = run(header, .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let entries = vendorEntries(active: active)\n if entries.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in entries {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "da6cf3f61cfa6251fe84cf8faeb056381280abaa": {"t": 1785750127.0263414, "n": 89689, "h": "6ddac4dccc432b05", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\n/// Squeeze a countdown (\"4d 1h\", \"2h 05m\", \"now\") down to its leading unit for\n/// the overview status-bar title, where every character fights for space:\n/// \"4d 1h\" → \"4d\", \"2h 05m\" → \"2h\", \"0h 05m\" → \"5m\". Missing/em-dash → nil.\nfunc shortReset(_ r: String) -> String? {\n guard !r.isEmpty, r != \"—\" else { return nil }\n let parts = r.split(separator: \" \")\n guard let first = parts.first else { return nil }\n if first == \"0h\", parts.count > 1 {\n let m = parts[1].drop(while: { $0 == \"0\" })\n return m == \"m\" ? \"0m\" : String(m)\n }\n return String(first)\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n// ── Anthropic multi-account ────────────────────────────────────────────────\n// Mirrors the Rust side (`src/config.rs`): explicit `[[anthropic.accounts]]`\n// entries plus subdirectories of `[anthropic] accounts_dir` holding a\n// `.credentials.json`, explicit labels winning on a clash. Each account is a\n// selectable entry with the pseudo-id `anthropic@<label>`, fetched by the\n// binary as `--vendor anthropic --account <label>` — the binary resolves the\n// account's credentials itself (file or its own Keychain item), so the app\n// never needs to know where they live.\n\n/// `[[anthropic.accounts]]` labels, in file order. Pure text scan, like the\n/// rest of this app's TOML reading: a `[[anthropic.accounts]]` header opens a\n/// block, the first `label = \"…\"` inside it counts.\nfunc anthropicAccountLabels(inTOML text: String) -> [String] {\n var labels: [String] = []\n var inBlock = false\n for rawLine in text.components(separatedBy: .newlines) {\n let line = rawLine.trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inBlock = line == \"[[anthropic.accounts]]\"\n continue\n }\n guard inBlock, line.hasPrefix(\"label\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == \"label\" else { continue }\n var value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n value = String(content[..<end])\n }\n if !value.isEmpty {\n labels.append(value)\n inBlock = false // one label per block\n }\n }\n return labels\n}\n\n/// Subdirectories of `dir` holding a `.credentials.json`, sorted — the same\n/// discovery rule as the Rust `accounts_dir` scan.\nfunc discoverAccountLabels(inDir dir: String) -> [String] {\n let fm = FileManager.default\n guard let names = try? fm.contentsOfDirectory(atPath: dir) else { return [] }\n return names.filter { name in\n var isDir: ObjCBool = false\n let sub = \"\\(dir)/\\(name)\"\n return fm.fileExists(atPath: sub, isDirectory: &isDir) && isDir.boolValue\n && fm.fileExists(atPath: \"\\(sub)/.credentials.json\")\n }.sorted()\n}\n\n/// Explicit labels first (file order), then discovered ones that don't clash.\nfunc mergedAccountLabels(explicit: [String], discovered: [String]) -> [String] {\n explicit + discovered.filter { !explicit.contains($0) }\n}\n\n/// All configured Claude account labels, from the same config the binary reads.\nfunc claudeAccountLabels() -> [String] {\n guard let text = try? String(contentsOfFile: configPathTOML(), encoding: .utf8) else {\n return []\n }\n let explicit = anthropicAccountLabels(inTOML: text)\n var discovered: [String] = []\n if let dir = tomlValueInText(text, section: \"anthropic\", key: \"accounts_dir\"), !dir.isEmpty {\n discovered = discoverAccountLabels(inDir: NSString(string: dir).expandingTildeInPath)\n }\n return mergedAccountLabels(explicit: explicit, discovered: discovered)\n}\n\n/// Rust semantics (`show_default_account`): `false` hides the default\n/// (unnamed) Claude entry, but is ignored when there are no named accounts,\n/// so Anthropic never loses its only entry.\nfunc showDefaultClaudeAccount(configValue: String?, hasAccounts: Bool) -> Bool {\n guard hasAccounts else { return true }\n return configValue != \"false\"\n}\n\n/// Pseudo-id mapping: `anthropic@<label>` selects a named account.\nlet ACCOUNT_ID_PREFIX = \"anthropic@\"\n\nfunc accountLabel(of id: String) -> String? {\n id.hasPrefix(ACCOUNT_ID_PREFIX) ? String(id.dropFirst(ACCOUNT_ID_PREFIX.count)) : nil\n}\n\nfunc baseVendorId(_ id: String) -> String {\n accountLabel(of: id) != nil ? \"anthropic\" : id\n}\n\n/// The subprocess arguments that select `id` (base vendor or named account).\nfunc vendorArgs(for id: String) -> [String] {\n if let label = accountLabel(of: id) {\n return [\"--vendor\", \"anthropic\", \"--account\", label]\n }\n return [\"--vendor\", id]\n}\n\n/// One selectable entry: a base vendor or one Claude account.\nstruct MenuEntry {\n let id: String // \"cursor\", \"anthropic\", or \"anthropic@<label>\"\n let name: String // display: \"Cursor\", \"Claude · <label>\"\n}\n\n/// The selectable entries, in menu order: every enabled+configured vendor,\n/// with Anthropic expanded into its named accounts (the default entry kept\n/// only per `show_default_account`). `active` stays listed even when\n/// unconfigured — same rule the per-vendor list always had.\nfunc vendorEntries(active: String) -> [MenuEntry] {\n var out: [MenuEntry] = []\n for v in VENDOR_AUTH where vendorEnabled(v) {\n if v.id == \"anthropic\" {\n let labels = claudeAccountLabels()\n let showDefault = showDefaultClaudeAccount(\n configValue: configValueTOML(\"anthropic\", \"show_default_account\"),\n hasAccounts: !labels.isEmpty)\n if showDefault && (v.id == active || vendorConfigured(v)) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n for label in labels {\n out.append(MenuEntry(id: ACCOUNT_ID_PREFIX + label, name: \"Claude · \\(label)\"))\n }\n } else if v.id == active || vendorConfigured(v) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n }\n return out\n}\n\n/// Display name for any selectable id (base vendor, account, or overview).\nfunc entryDisplayName(_ id: String) -> String {\n if id == \"overview\" { return \"Visão geral\" }\n if let label = accountLabel(of: id) { return \"Claude · \\(label)\" }\n return VENDOR_AUTH.first { $0.id == id }?.name ?? id\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"compactShortcutEnabled\") private var compactShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n @AppStorage(\"launchAtLogin\") private var launchAtLogin = false\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker. Claude\n // accounts appear as their `anthropic@<label>` pseudo-ids, same as the\n // \"Trocar vendor\" submenu.\n private var vendors: [String] {\n var ids = VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n let labels = claudeAccountLabels()\n if let at = ids.firstIndex(of: \"anthropic\") {\n ids.insert(contentsOf: labels.map { ACCOUNT_ID_PREFIX + $0 }, at: at + 1)\n }\n return ids\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n Toggle(\"Compactar/Expandir com ⌥⌘E (atalho global)\", isOn: $compactShortcutEnabled)\n Text(\"Alterna a Visão geral entre barras e modo compacto.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Sistema\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Iniciar no login\", isOn: $launchAtLogin)\n .onChange(of: launchAtLogin) { value in setLaunchAtLogin(value) }\n Text(\"Instala um LaunchAgent que sobe o app ao entrar na sua conta.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n }\n}\n\n// ─── Launch at login (macOS LaunchAgent) ─────────────────────────────────\n//\n// The app is an unbundled single binary, so the modern SMAppService.mainApp\n// API (which needs a bundle id) doesn't apply. A per-user LaunchAgent is the\n// portable way to start an unbundled executable at login — the same mechanism\n// install-agent.sh sets up, now toggleable from Preferences.\nlet LAUNCH_AGENT_LABEL = \"com.akitaonrails.ai-usagebar-menubar\"\n\nfunc launchAgentPlistPath() -> String {\n \"\\(NSHomeDirectory())/Library/LaunchAgents/\\(LAUNCH_AGENT_LABEL).plist\"\n}\n\n/// Absolute path of the running executable, for the LaunchAgent to relaunch.\nfunc selfExecutablePath() -> String {\n if let p = Bundle.main.executablePath, !p.isEmpty { return p }\n let arg0 = CommandLine.arguments.first ?? \"ai-usagebar-menubar\"\n if arg0.hasPrefix(\"/\") { return arg0 }\n return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)\n .appendingPathComponent(arg0).standardized.path\n}\n\n/// Install (or remove) the login LaunchAgent by managing its plist file.\n/// Building the plist via PropertyListSerialization sidesteps the XML-escaping\n/// the shell installer has to do by hand.\n///\n/// Deliberately no `launchctl load`/`unload`: the app is already running when\n/// this is toggled, so `RunAtLoad` on load would spawn a *second* copy, and\n/// `unload` on disable would SIGTERM this very process (quitting the app the\n/// user is still using) if it happened to be the launchd-managed one. launchd\n/// loads every agent in ~/Library/LaunchAgents at the next login, so writing\n/// (or removing) the file is all that's needed for a start-at-login toggle.\nfunc setLaunchAtLogin(_ enabled: Bool) {\n let path = launchAgentPlistPath()\n if enabled {\n let dir = (path as NSString).deletingLastPathComponent\n try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)\n let plist: [String: Any] = [\n \"Label\": LAUNCH_AGENT_LABEL,\n \"ProgramArguments\": [selfExecutablePath()],\n \"RunAtLoad\": true,\n \"ProcessType\": \"Interactive\",\n ]\n if let data = try? PropertyListSerialization.data(\n fromPropertyList: plist, format: .xml, options: 0) {\n try? data.write(to: URL(fileURLWithPath: path))\n }\n } else {\n try? FileManager.default.removeItem(atPath: path)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\nlet compactHotKeyNotification = Notification.Name(\"aiusagebar.compactHotKey\")\n\n/// Default shortcuts: `\\` (kVK_ANSI_Backslash) with Command+Option swaps the\n/// vendor; `E` with Command+Option toggles the overview's Compactar/Expandir.\n/// `[ui]` on Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet COMPACT_HOTKEY_KEYCODE = UInt32(kVK_ANSI_E)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\n/// Registered hot-key ids, carried back to us in the event so one shared C\n/// callback can tell which shortcut fired.\nlet SWAP_HOTKEY_ID = UInt32(1)\nlet COMPACT_HOTKEY_ID = UInt32(2)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n var hk = EventHotKeyID()\n GetEventParameter(event, EventParamName(kEventParamDirectObject),\n EventParamType(typeEventHotKeyID), nil,\n MemoryLayout<EventHotKeyID>.size, nil, &hk)\n NotificationCenter.default.post(\n name: hk.id == COMPACT_HOTKEY_ID ? compactHotKeyNotification : swapHotKeyNotification,\n object: nil)\n return noErr\n}\n\n/// Whether the overview status-bar title draws mini bars (vs. the compact\n/// %-text mode). \"Compactar\" forces the text mode even under the bars-count\n/// threshold. Pure + testable — the render path is not.\nfunc overviewUsesBars(count: Int, barsMax: Int, compact: Bool) -> Bool {\n !compact && count <= barsMax\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var compactHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor); hidden in single-vendor mode.\n // A fixed pool avoids rebuilding the menu (which would disrupt an open menu).\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n /// Overview-only: forces the status-bar title into the compact %-text mode\n /// (\"Compactar\"); while compact it reads \"Expandir\" and turns it back off.\n let compactItem = NSMenuItem(title: \"Compactar\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true, \"compactShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleCompactHotKey),\n name: compactHotKeyNotification, object: nil)\n installSwapHotKey()\n installCompactHotKey()\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n removeSwapHotKey()\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n installHotKeyHandlerOnce()\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: SWAP_HOTKEY_ID) // 'AIBB'\n RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &swapHotKeyRef)\n }\n\n /// (Re)register the global ⌥⌘E Compactar/Expandir hot key to match the\n /// `compactShortcutEnabled` preference. Idempotent, mirroring\n /// `installSwapHotKey` — including the painted-in hint (a native\n /// keyEquivalent would fire a second time while the menu is open, double-\n /// toggling on one press).\n func installCompactHotKey() {\n if let ref = compactHotKeyRef {\n UnregisterEventHotKey(ref)\n compactHotKeyRef = nil\n }\n updateCompactItemTitle()\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else { return }\n installHotKeyHandlerOnce()\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: COMPACT_HOTKEY_ID)\n RegisterEventHotKey(\n COMPACT_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &compactHotKeyRef)\n }\n\n /// Compactar ↔ Expandir label plus the dimmed ⌥⌘E hint when the global\n /// shortcut is on. Shared by the overview render and the hot-key installer.\n func updateCompactItemTitle() {\n let label = DEF.bool(forKey: \"overviewCompact\") ? \"Expandir\" : \"Compactar\"\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else {\n compactItem.attributedTitle = NSAttributedString(string: label)\n return\n }\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(string: \"\\(label) \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘E\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n compactItem.attributedTitle = s\n }\n\n /// One Carbon handler serves every hot key; the event's EventHotKeyID\n /// says which one fired.\n private func installHotKeyHandlerOnce() {\n guard !swapHotKeyHandlerInstalled else { return }\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n InstallEventHandler(GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n swapHotKeyHandlerInstalled = true\n }\n\n func removeSwapHotKey() {\n if let ref = swapHotKeyRef {\n UnregisterEventHotKey(ref)\n swapHotKeyRef = nil\n }\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// ⌥⌘E: toggle Compactar/Expandir. Overview-only — flipping a hidden\n /// preference from another view would be invisible and confusing.\n @objc func handleCompactHotKey() {\n guard VENDOR == \"overview\" else { return }\n toggleCompact()\n }\n\n /// The swap-shortcut ring: every configured entry (vendors *and* Claude\n /// accounts) plus the synthetic \"overview\" target, so ⌥⌘\\ cycles them all.\n func swapCycleIds() -> [String] {\n var ids = vendorEntries(active: VENDOR).map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per possible vendor for Overview mode. ponytail: 12 ≥\n // the vendor count; bump if VENDOR_AUTH ever grows past it.\n for _ in 0..<12 {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n // First item after the usage rows, Overview-only (hidden elsewhere).\n compactItem.action = #selector(toggleCompact)\n compactItem.target = self\n compactItem.isHidden = true\n menu.addItem(compactItem)\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n /// Flip the overview's compact mode; the UserDefaults observer re-renders,\n /// which also relabels the item (Compactar ↔ Expandir).\n @objc func toggleCompact() {\n DEF.set(!DEF.bool(forKey: \"overviewCompact\"), forKey: \"overviewCompact\")\n }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n if DEF.bool(forKey: \"compactShortcutEnabled\") != (compactHotKeyRef != nil) {\n installCompactHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = entryDisplayName(VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: vendor) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n // vendorEntries(active: \"\") keeps only configured entries — the\n // active-vendor courtesy slot has no place in an all-vendors sweep.\n let entries = vendorEntries(active: \"\")\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in entries {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: v.id) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: baseVendorId(v.id))\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// Compact bar label for a vendor name. Usually the first word; for a\n /// Claude account (\"Claude · gmail\") the account label is the part that\n /// distinguishes entries, so use it instead of the shared \"Claude\".\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let base = name.range(of: \" · \").map { String(name[$0.upperBound...]) }\n ?? String(name.split(separator: \" \").first ?? Substring(name))\n return base.count <= maxLen ? base : String(base.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 4) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String, reset: String?)] =\n items.compactMap {\n guard let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\", nil) }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\", h.reset.flatMap(shortReset))\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 4\n let compact = DEF.bool(forKey: \"overviewCompact\")\n let t = NSMutableAttributedString()\n if overviewUsesBars(count: heads.count, barsMax: barsMax, compact: compact) {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n // Entry order, not worst-first: entries are provider-grouped\n // (standard Claude, then its accounts, then the other vendors), and\n // stable positions beat a pct sort that reshuffles labels on every\n // refresh.\n for (i, e) in heads.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n }\n if heads.count > maxN { t.append(run(\" +\\(heads.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to jump straight to that vendor's detail view.\n it.representedObject = item.id\n it.target = self\n it.action = #selector(switchVendor(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n compactItem.isHidden = false\n updateCompactItemTitle()\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: baseVendorId(VENDOR)) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n // With several Claude accounts the plan alone (\"Claude Max 20x\") no\n // longer says WHICH account this is — suffix the label.\n let plan = s.plan.isEmpty ? \"AI Usage\" : s.plan\n let header = accountLabel(of: VENDOR).map { \"\\(plan) · \\($0)\" } ?? plan\n headerItem.attributedTitle = run(header, .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let entries = vendorEntries(active: active)\n if entries.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in entries {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "a83aa06412249699f6a57758ba642c7c9e094a27": {"t": 1785750128.8722606, "n": 86677, "h": "f5c21eb827f4cfe7", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\n/// Squeeze a countdown (\"4d 1h\", \"2h 05m\", \"now\") down to its leading unit for\n/// the overview status-bar title, where every character fights for space:\n/// \"4d 1h\" → \"4d\", \"2h 05m\" → \"2h\", \"0h 05m\" → \"5m\". Missing/em-dash → nil.\nfunc shortReset(_ r: String) -> String? {\n guard !r.isEmpty, r != \"—\" else { return nil }\n let parts = r.split(separator: \" \")\n guard let first = parts.first else { return nil }\n if first == \"0h\", parts.count > 1 {\n let m = parts[1].drop(while: { $0 == \"0\" })\n return m == \"m\" ? \"0m\" : String(m)\n }\n return String(first)\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n// ── Anthropic multi-account ────────────────────────────────────────────────\n// Mirrors the Rust side (`src/config.rs`): explicit `[[anthropic.accounts]]`\n// entries plus subdirectories of `[anthropic] accounts_dir` holding a\n// `.credentials.json`, explicit labels winning on a clash. Each account is a\n// selectable entry with the pseudo-id `anthropic@<label>`, fetched by the\n// binary as `--vendor anthropic --account <label>` — the binary resolves the\n// account's credentials itself (file or its own Keychain item), so the app\n// never needs to know where they live.\n\n/// `[[anthropic.accounts]]` labels, in file order. Pure text scan, like the\n/// rest of this app's TOML reading: a `[[anthropic.accounts]]` header opens a\n/// block, the first `label = \"…\"` inside it counts.\nfunc anthropicAccountLabels(inTOML text: String) -> [String] {\n var labels: [String] = []\n var inBlock = false\n for rawLine in text.components(separatedBy: .newlines) {\n let line = rawLine.trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inBlock = line == \"[[anthropic.accounts]]\"\n continue\n }\n guard inBlock, line.hasPrefix(\"label\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == \"label\" else { continue }\n var value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n value = String(content[..<end])\n }\n if !value.isEmpty {\n labels.append(value)\n inBlock = false // one label per block\n }\n }\n return labels\n}\n\n/// Subdirectories of `dir` holding a `.credentials.json`, sorted — the same\n/// discovery rule as the Rust `accounts_dir` scan.\nfunc discoverAccountLabels(inDir dir: String) -> [String] {\n let fm = FileManager.default\n guard let names = try? fm.contentsOfDirectory(atPath: dir) else { return [] }\n return names.filter { name in\n var isDir: ObjCBool = false\n let sub = \"\\(dir)/\\(name)\"\n return fm.fileExists(atPath: sub, isDirectory: &isDir) && isDir.boolValue\n && fm.fileExists(atPath: \"\\(sub)/.credentials.json\")\n }.sorted()\n}\n\n/// Explicit labels first (file order), then discovered ones that don't clash.\nfunc mergedAccountLabels(explicit: [String], discovered: [String]) -> [String] {\n explicit + discovered.filter { !explicit.contains($0) }\n}\n\n/// All configured Claude account labels, from the same config the binary reads.\nfunc claudeAccountLabels() -> [String] {\n guard let text = try? String(contentsOfFile: configPathTOML(), encoding: .utf8) else {\n return []\n }\n let explicit = anthropicAccountLabels(inTOML: text)\n var discovered: [String] = []\n if let dir = tomlValueInText(text, section: \"anthropic\", key: \"accounts_dir\"), !dir.isEmpty {\n discovered = discoverAccountLabels(inDir: NSString(string: dir).expandingTildeInPath)\n }\n return mergedAccountLabels(explicit: explicit, discovered: discovered)\n}\n\n/// Rust semantics (`show_default_account`): `false` hides the default\n/// (unnamed) Claude entry, but is ignored when there are no named accounts,\n/// so Anthropic never loses its only entry.\nfunc showDefaultClaudeAccount(configValue: String?, hasAccounts: Bool) -> Bool {\n guard hasAccounts else { return true }\n return configValue != \"false\"\n}\n\n/// Pseudo-id mapping: `anthropic@<label>` selects a named account.\nlet ACCOUNT_ID_PREFIX = \"anthropic@\"\n\nfunc accountLabel(of id: String) -> String? {\n id.hasPrefix(ACCOUNT_ID_PREFIX) ? String(id.dropFirst(ACCOUNT_ID_PREFIX.count)) : nil\n}\n\nfunc baseVendorId(_ id: String) -> String {\n accountLabel(of: id) != nil ? \"anthropic\" : id\n}\n\n/// The subprocess arguments that select `id` (base vendor or named account).\nfunc vendorArgs(for id: String) -> [String] {\n if let label = accountLabel(of: id) {\n return [\"--vendor\", \"anthropic\", \"--account\", label]\n }\n return [\"--vendor\", id]\n}\n\n/// One selectable entry: a base vendor or one Claude account.\nstruct MenuEntry {\n let id: String // \"cursor\", \"anthropic\", or \"anthropic@<label>\"\n let name: String // display: \"Cursor\", \"Claude · <label>\"\n}\n\n/// The selectable entries, in menu order: every enabled+configured vendor,\n/// with Anthropic expanded into its named accounts (the default entry kept\n/// only per `show_default_account`). `active` stays listed even when\n/// unconfigured — same rule the per-vendor list always had.\nfunc vendorEntries(active: String) -> [MenuEntry] {\n var out: [MenuEntry] = []\n for v in VENDOR_AUTH where vendorEnabled(v) {\n if v.id == \"anthropic\" {\n let labels = claudeAccountLabels()\n let showDefault = showDefaultClaudeAccount(\n configValue: configValueTOML(\"anthropic\", \"show_default_account\"),\n hasAccounts: !labels.isEmpty)\n if showDefault && (v.id == active || vendorConfigured(v)) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n for label in labels {\n out.append(MenuEntry(id: ACCOUNT_ID_PREFIX + label, name: \"Claude · \\(label)\"))\n }\n } else if v.id == active || vendorConfigured(v) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n }\n return out\n}\n\n/// Display name for any selectable id (base vendor, account, or overview).\nfunc entryDisplayName(_ id: String) -> String {\n if id == \"overview\" { return \"Visão geral\" }\n if let label = accountLabel(of: id) { return \"Claude · \\(label)\" }\n return VENDOR_AUTH.first { $0.id == id }?.name ?? id\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"compactShortcutEnabled\") private var compactShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker. Claude\n // accounts appear as their `anthropic@<label>` pseudo-ids, same as the\n // \"Trocar vendor\" submenu.\n private var vendors: [String] {\n var ids = VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n let labels = claudeAccountLabels()\n if let at = ids.firstIndex(of: \"anthropic\") {\n ids.insert(contentsOf: labels.map { ACCOUNT_ID_PREFIX + $0 }, at: at + 1)\n }\n return ids\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n Toggle(\"Compactar/Expandir com ⌥⌘E (atalho global)\", isOn: $compactShortcutEnabled)\n Text(\"Alterna a Visão geral entre barras e modo compacto.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\nlet compactHotKeyNotification = Notification.Name(\"aiusagebar.compactHotKey\")\n\n/// Default shortcuts: `\\` (kVK_ANSI_Backslash) with Command+Option swaps the\n/// vendor; `E` with Command+Option toggles the overview's Compactar/Expandir.\n/// `[ui]` on Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet COMPACT_HOTKEY_KEYCODE = UInt32(kVK_ANSI_E)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\n/// Registered hot-key ids, carried back to us in the event so one shared C\n/// callback can tell which shortcut fired.\nlet SWAP_HOTKEY_ID = UInt32(1)\nlet COMPACT_HOTKEY_ID = UInt32(2)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n var hk = EventHotKeyID()\n GetEventParameter(event, EventParamName(kEventParamDirectObject),\n EventParamType(typeEventHotKeyID), nil,\n MemoryLayout<EventHotKeyID>.size, nil, &hk)\n NotificationCenter.default.post(\n name: hk.id == COMPACT_HOTKEY_ID ? compactHotKeyNotification : swapHotKeyNotification,\n object: nil)\n return noErr\n}\n\n/// Whether the overview status-bar title draws mini bars (vs. the compact\n/// %-text mode). \"Compactar\" forces the text mode even under the bars-count\n/// threshold. Pure + testable — the render path is not.\nfunc overviewUsesBars(count: Int, barsMax: Int, compact: Bool) -> Bool {\n !compact && count <= barsMax\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var compactHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor); hidden in single-vendor mode.\n // A fixed pool avoids rebuilding the menu (which would disrupt an open menu).\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n /// Overview-only: forces the status-bar title into the compact %-text mode\n /// (\"Compactar\"); while compact it reads \"Expandir\" and turns it back off.\n let compactItem = NSMenuItem(title: \"Compactar\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true, \"compactShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleCompactHotKey),\n name: compactHotKeyNotification, object: nil)\n installSwapHotKey()\n installCompactHotKey()\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n removeSwapHotKey()\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n installHotKeyHandlerOnce()\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: SWAP_HOTKEY_ID) // 'AIBB'\n RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &swapHotKeyRef)\n }\n\n /// (Re)register the global ⌥⌘E Compactar/Expandir hot key to match the\n /// `compactShortcutEnabled` preference. Idempotent, mirroring\n /// `installSwapHotKey` — including the painted-in hint (a native\n /// keyEquivalent would fire a second time while the menu is open, double-\n /// toggling on one press).\n func installCompactHotKey() {\n if let ref = compactHotKeyRef {\n UnregisterEventHotKey(ref)\n compactHotKeyRef = nil\n }\n updateCompactItemTitle()\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else { return }\n installHotKeyHandlerOnce()\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: COMPACT_HOTKEY_ID)\n RegisterEventHotKey(\n COMPACT_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &compactHotKeyRef)\n }\n\n /// Compactar ↔ Expandir label plus the dimmed ⌥⌘E hint when the global\n /// shortcut is on. Shared by the overview render and the hot-key installer.\n func updateCompactItemTitle() {\n let label = DEF.bool(forKey: \"overviewCompact\") ? \"Expandir\" : \"Compactar\"\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else {\n compactItem.attributedTitle = NSAttributedString(string: label)\n return\n }\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(string: \"\\(label) \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘E\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n compactItem.attributedTitle = s\n }\n\n /// One Carbon handler serves every hot key; the event's EventHotKeyID\n /// says which one fired.\n private func installHotKeyHandlerOnce() {\n guard !swapHotKeyHandlerInstalled else { return }\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n InstallEventHandler(GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n swapHotKeyHandlerInstalled = true\n }\n\n func removeSwapHotKey() {\n if let ref = swapHotKeyRef {\n UnregisterEventHotKey(ref)\n swapHotKeyRef = nil\n }\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// ⌥⌘E: toggle Compactar/Expandir. Overview-only — flipping a hidden\n /// preference from another view would be invisible and confusing.\n @objc func handleCompactHotKey() {\n guard VENDOR == \"overview\" else { return }\n toggleCompact()\n }\n\n /// The swap-shortcut ring: every configured entry (vendors *and* Claude\n /// accounts) plus the synthetic \"overview\" target, so ⌥⌘\\ cycles them all.\n func swapCycleIds() -> [String] {\n var ids = vendorEntries(active: VENDOR).map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per possible vendor for Overview mode. ponytail: 12 ≥\n // the vendor count; bump if VENDOR_AUTH ever grows past it.\n for _ in 0..<12 {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n // First item after the usage rows, Overview-only (hidden elsewhere).\n compactItem.action = #selector(toggleCompact)\n compactItem.target = self\n compactItem.isHidden = true\n menu.addItem(compactItem)\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n /// Flip the overview's compact mode; the UserDefaults observer re-renders,\n /// which also relabels the item (Compactar ↔ Expandir).\n @objc func toggleCompact() {\n DEF.set(!DEF.bool(forKey: \"overviewCompact\"), forKey: \"overviewCompact\")\n }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n if DEF.bool(forKey: \"compactShortcutEnabled\") != (compactHotKeyRef != nil) {\n installCompactHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = entryDisplayName(VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: vendor) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n // vendorEntries(active: \"\") keeps only configured entries — the\n // active-vendor courtesy slot has no place in an all-vendors sweep.\n let entries = vendorEntries(active: \"\")\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in entries {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: v.id) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: baseVendorId(v.id))\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// Compact bar label for a vendor name. Usually the first word; for a\n /// Claude account (\"Claude · gmail\") the account label is the part that\n /// distinguishes entries, so use it instead of the shared \"Claude\".\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let base = name.range(of: \" · \").map { String(name[$0.upperBound...]) }\n ?? String(name.split(separator: \" \").first ?? Substring(name))\n return base.count <= maxLen ? base : String(base.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 4) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String, reset: String?)] =\n items.compactMap {\n guard let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\", nil) }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\", h.reset.flatMap(shortReset))\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 4\n let compact = DEF.bool(forKey: \"overviewCompact\")\n let t = NSMutableAttributedString()\n if overviewUsesBars(count: heads.count, barsMax: barsMax, compact: compact) {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n // Entry order, not worst-first: entries are provider-grouped\n // (standard Claude, then its accounts, then the other vendors), and\n // stable positions beat a pct sort that reshuffles labels on every\n // refresh.\n for (i, e) in heads.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n }\n if heads.count > maxN { t.append(run(\" +\\(heads.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to jump straight to that vendor's detail view.\n it.representedObject = item.id\n it.target = self\n it.action = #selector(switchVendor(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n compactItem.isHidden = false\n updateCompactItemTitle()\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: baseVendorId(VENDOR)) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n // With several Claude accounts the plan alone (\"Claude Max 20x\") no\n // longer says WHICH account this is — suffix the label.\n let plan = s.plan.isEmpty ? \"AI Usage\" : s.plan\n let header = accountLabel(of: VENDOR).map { \"\\(plan) · \\($0)\" } ?? plan\n headerItem.attributedTitle = run(header, .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let entries = vendorEntries(active: active)\n if entries.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in entries {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "da93a2e8de247faef41749a7030309e88d54da64": {"t": 1785750131.2839606, "n": 80142, "h": "83a8159dd9969163", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\n/// Squeeze a countdown (\"4d 1h\", \"2h 05m\", \"now\") down to its leading unit for\n/// the overview status-bar title, where every character fights for space:\n/// \"4d 1h\" → \"4d\", \"2h 05m\" → \"2h\", \"0h 05m\" → \"5m\". Missing/em-dash → nil.\nfunc shortReset(_ r: String) -> String? {\n guard !r.isEmpty, r != \"—\" else { return nil }\n let parts = r.split(separator: \" \")\n guard let first = parts.first else { return nil }\n if first == \"0h\", parts.count > 1 {\n let m = parts[1].drop(while: { $0 == \"0\" })\n return m == \"m\" ? \"0m\" : String(m)\n }\n return String(first)\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"compactShortcutEnabled\") private var compactShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker.\n private var vendors: [String] {\n VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n Toggle(\"Compactar/Expandir com ⌥⌘E (atalho global)\", isOn: $compactShortcutEnabled)\n Text(\"Alterna a Visão geral entre barras e modo compacto.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\nlet compactHotKeyNotification = Notification.Name(\"aiusagebar.compactHotKey\")\n\n/// Default shortcuts: `\\` (kVK_ANSI_Backslash) with Command+Option swaps the\n/// vendor; `E` with Command+Option toggles the overview's Compactar/Expandir.\n/// `[ui]` on Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet COMPACT_HOTKEY_KEYCODE = UInt32(kVK_ANSI_E)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\n/// Registered hot-key ids, carried back to us in the event so one shared C\n/// callback can tell which shortcut fired.\nlet SWAP_HOTKEY_ID = UInt32(1)\nlet COMPACT_HOTKEY_ID = UInt32(2)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n var hk = EventHotKeyID()\n GetEventParameter(event, EventParamName(kEventParamDirectObject),\n EventParamType(typeEventHotKeyID), nil,\n MemoryLayout<EventHotKeyID>.size, nil, &hk)\n NotificationCenter.default.post(\n name: hk.id == COMPACT_HOTKEY_ID ? compactHotKeyNotification : swapHotKeyNotification,\n object: nil)\n return noErr\n}\n\n/// Whether the overview status-bar title draws mini bars (vs. the compact\n/// %-text mode). \"Compactar\" forces the text mode even under the bars-count\n/// threshold. Pure + testable — the render path is not.\nfunc overviewUsesBars(count: Int, barsMax: Int, compact: Bool) -> Bool {\n !compact && count <= barsMax\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var compactHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor); hidden in single-vendor mode.\n // A fixed pool avoids rebuilding the menu (which would disrupt an open menu).\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n /// Overview-only: forces the status-bar title into the compact %-text mode\n /// (\"Compactar\"); while compact it reads \"Expandir\" and turns it back off.\n let compactItem = NSMenuItem(title: \"Compactar\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true, \"compactShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleCompactHotKey),\n name: compactHotKeyNotification, object: nil)\n installSwapHotKey()\n installCompactHotKey()\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n removeSwapHotKey()\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n installHotKeyHandlerOnce()\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: SWAP_HOTKEY_ID) // 'AIBB'\n RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &swapHotKeyRef)\n }\n\n /// (Re)register the global ⌥⌘E Compactar/Expandir hot key to match the\n /// `compactShortcutEnabled` preference. Idempotent, mirroring\n /// `installSwapHotKey` — including the painted-in hint (a native\n /// keyEquivalent would fire a second time while the menu is open, double-\n /// toggling on one press).\n func installCompactHotKey() {\n if let ref = compactHotKeyRef {\n UnregisterEventHotKey(ref)\n compactHotKeyRef = nil\n }\n updateCompactItemTitle()\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else { return }\n installHotKeyHandlerOnce()\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: COMPACT_HOTKEY_ID)\n RegisterEventHotKey(\n COMPACT_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &compactHotKeyRef)\n }\n\n /// Compactar ↔ Expandir label plus the dimmed ⌥⌘E hint when the global\n /// shortcut is on. Shared by the overview render and the hot-key installer.\n func updateCompactItemTitle() {\n let label = DEF.bool(forKey: \"overviewCompact\") ? \"Expandir\" : \"Compactar\"\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else {\n compactItem.attributedTitle = NSAttributedString(string: label)\n return\n }\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(string: \"\\(label) \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘E\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n compactItem.attributedTitle = s\n }\n\n /// One Carbon handler serves every hot key; the event's EventHotKeyID\n /// says which one fired.\n private func installHotKeyHandlerOnce() {\n guard !swapHotKeyHandlerInstalled else { return }\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n InstallEventHandler(GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n swapHotKeyHandlerInstalled = true\n }\n\n func removeSwapHotKey() {\n if let ref = swapHotKeyRef {\n UnregisterEventHotKey(ref)\n swapHotKeyRef = nil\n }\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// ⌥⌘E: toggle Compactar/Expandir. Overview-only — flipping a hidden\n /// preference from another view would be invisible and confusing.\n @objc func handleCompactHotKey() {\n guard VENDOR == \"overview\" else { return }\n toggleCompact()\n }\n\n /// The swap-shortcut ring: every configured vendor plus the synthetic\n /// \"overview\" target, so ⌥⌘\\ cycles all providers *and* the overview.\n func swapCycleIds() -> [String] {\n var ids = VENDOR_AUTH\n .filter { vendorEnabled($0) && ($0.id == VENDOR || vendorConfigured($0)) }\n .map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per possible vendor for Overview mode. ponytail: 12 ≥\n // the vendor count; bump if VENDOR_AUTH ever grows past it.\n for _ in 0..<12 {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n // First item after the usage rows, Overview-only (hidden elsewhere).\n compactItem.action = #selector(toggleCompact)\n compactItem.target = self\n compactItem.isHidden = true\n menu.addItem(compactItem)\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n /// Flip the overview's compact mode; the UserDefaults observer re-renders,\n /// which also relabels the item (Compactar ↔ Expandir).\n @objc func toggleCompact() {\n DEF.set(!DEF.bool(forKey: \"overviewCompact\"), forKey: \"overviewCompact\")\n }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n if DEF.bool(forKey: \"compactShortcutEnabled\") != (compactHotKeyRef != nil) {\n installCompactHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = VENDOR == \"overview\"\n ? \"Visão geral\"\n : (VENDOR_AUTH.first { $0.id == VENDOR }?.name ?? VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = [\"--vendor\", vendor, \"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n let vendors = VENDOR_AUTH.filter { vendorEnabled($0) && vendorConfigured($0) }\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in vendors {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = [\"--vendor\", v.id, \"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: v.id)\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// First word of a vendor name, clipped to `maxLen` — a compact bar label.\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let first = String(name.split(separator: \" \").first ?? Substring(name))\n return first.count <= maxLen ? first : String(first.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 4) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String, reset: String?)] =\n items.compactMap {\n guard let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\", nil) }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\", h.reset.flatMap(shortReset))\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 4\n let compact = DEF.bool(forKey: \"overviewCompact\")\n let t = NSMutableAttributedString()\n if overviewUsesBars(count: heads.count, barsMax: barsMax, compact: compact) {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n // Entry order, not worst-first: entries are provider-grouped\n // (standard Claude, then its accounts, then the other vendors), and\n // stable positions beat a pct sort that reshuffles labels on every\n // refresh.\n for (i, e) in heads.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n if let r = e.reset { t.append(run(\" \\(r)\", secondary)) }\n }\n if heads.count > maxN { t.append(run(\" +\\(heads.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to jump straight to that vendor's detail view.\n it.representedObject = item.id\n it.target = self\n it.action = #selector(switchVendor(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n compactItem.isHidden = false\n updateCompactItemTitle()\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: VENDOR) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n headerItem.attributedTitle = run(s.plan.isEmpty ? \"AI Usage\" : s.plan,\n .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let configured = VENDOR_AUTH.filter { vendorEnabled($0) && ($0.id == active || vendorConfigured($0)) }\n if configured.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in configured {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "7e3110da703fa3b06873277ccd7b4450b5814a07": {"t": 1785750133.0217896, "n": 88869, "h": "bf880712ec1b1d19", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n// ── Anthropic multi-account ────────────────────────────────────────────────\n// Mirrors the Rust side (`src/config.rs`): explicit `[[anthropic.accounts]]`\n// entries plus subdirectories of `[anthropic] accounts_dir` holding a\n// `.credentials.json`, explicit labels winning on a clash. Each account is a\n// selectable entry with the pseudo-id `anthropic@<label>`, fetched by the\n// binary as `--vendor anthropic --account <label>` — the binary resolves the\n// account's credentials itself (file or its own Keychain item), so the app\n// never needs to know where they live.\n\n/// `[[anthropic.accounts]]` labels, in file order. Pure text scan, like the\n/// rest of this app's TOML reading: a `[[anthropic.accounts]]` header opens a\n/// block, the first `label = \"…\"` inside it counts.\nfunc anthropicAccountLabels(inTOML text: String) -> [String] {\n var labels: [String] = []\n var inBlock = false\n for rawLine in text.components(separatedBy: .newlines) {\n let line = rawLine.trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inBlock = line == \"[[anthropic.accounts]]\"\n continue\n }\n guard inBlock, line.hasPrefix(\"label\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == \"label\" else { continue }\n var value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n value = String(content[..<end])\n }\n if !value.isEmpty {\n labels.append(value)\n inBlock = false // one label per block\n }\n }\n return labels\n}\n\n/// Subdirectories of `dir` holding a `.credentials.json`, sorted — the same\n/// discovery rule as the Rust `accounts_dir` scan.\nfunc discoverAccountLabels(inDir dir: String) -> [String] {\n let fm = FileManager.default\n guard let names = try? fm.contentsOfDirectory(atPath: dir) else { return [] }\n return names.filter { name in\n var isDir: ObjCBool = false\n let sub = \"\\(dir)/\\(name)\"\n return fm.fileExists(atPath: sub, isDirectory: &isDir) && isDir.boolValue\n && fm.fileExists(atPath: \"\\(sub)/.credentials.json\")\n }.sorted()\n}\n\n/// Explicit labels first (file order), then discovered ones that don't clash.\nfunc mergedAccountLabels(explicit: [String], discovered: [String]) -> [String] {\n explicit + discovered.filter { !explicit.contains($0) }\n}\n\n/// All configured Claude account labels, from the same config the binary reads.\nfunc claudeAccountLabels() -> [String] {\n guard let text = try? String(contentsOfFile: configPathTOML(), encoding: .utf8) else {\n return []\n }\n let explicit = anthropicAccountLabels(inTOML: text)\n var discovered: [String] = []\n if let dir = tomlValueInText(text, section: \"anthropic\", key: \"accounts_dir\"), !dir.isEmpty {\n discovered = discoverAccountLabels(inDir: NSString(string: dir).expandingTildeInPath)\n }\n return mergedAccountLabels(explicit: explicit, discovered: discovered)\n}\n\n/// Rust semantics (`show_default_account`): `false` hides the default\n/// (unnamed) Claude entry, but is ignored when there are no named accounts,\n/// so Anthropic never loses its only entry.\nfunc showDefaultClaudeAccount(configValue: String?, hasAccounts: Bool) -> Bool {\n guard hasAccounts else { return true }\n return configValue != \"false\"\n}\n\n/// Pseudo-id mapping: `anthropic@<label>` selects a named account.\nlet ACCOUNT_ID_PREFIX = \"anthropic@\"\n\nfunc accountLabel(of id: String) -> String? {\n id.hasPrefix(ACCOUNT_ID_PREFIX) ? String(id.dropFirst(ACCOUNT_ID_PREFIX.count)) : nil\n}\n\nfunc baseVendorId(_ id: String) -> String {\n accountLabel(of: id) != nil ? \"anthropic\" : id\n}\n\n/// The subprocess arguments that select `id` (base vendor or named account).\nfunc vendorArgs(for id: String) -> [String] {\n if let label = accountLabel(of: id) {\n return [\"--vendor\", \"anthropic\", \"--account\", label]\n }\n return [\"--vendor\", id]\n}\n\n/// One selectable entry: a base vendor or one Claude account.\nstruct MenuEntry {\n let id: String // \"cursor\", \"anthropic\", or \"anthropic@<label>\"\n let name: String // display: \"Cursor\", \"Claude · <label>\"\n}\n\n/// The selectable entries, in menu order: every enabled+configured vendor,\n/// with Anthropic expanded into its named accounts (the default entry kept\n/// only per `show_default_account`). `active` stays listed even when\n/// unconfigured — same rule the per-vendor list always had.\nfunc vendorEntries(active: String) -> [MenuEntry] {\n var out: [MenuEntry] = []\n for v in VENDOR_AUTH where vendorEnabled(v) {\n if v.id == \"anthropic\" {\n let labels = claudeAccountLabels()\n let showDefault = showDefaultClaudeAccount(\n configValue: configValueTOML(\"anthropic\", \"show_default_account\"),\n hasAccounts: !labels.isEmpty)\n if showDefault && (v.id == active || vendorConfigured(v)) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n for label in labels {\n out.append(MenuEntry(id: ACCOUNT_ID_PREFIX + label, name: \"Claude · \\(label)\"))\n }\n } else if v.id == active || vendorConfigured(v) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n }\n return out\n}\n\n/// Display name for any selectable id (base vendor, account, or overview).\nfunc entryDisplayName(_ id: String) -> String {\n if id == \"overview\" { return \"Visão geral\" }\n if let label = accountLabel(of: id) { return \"Claude · \\(label)\" }\n return VENDOR_AUTH.first { $0.id == id }?.name ?? id\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"compactShortcutEnabled\") private var compactShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n @AppStorage(\"launchAtLogin\") private var launchAtLogin = false\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker. Claude\n // accounts appear as their `anthropic@<label>` pseudo-ids, same as the\n // \"Trocar vendor\" submenu.\n private var vendors: [String] {\n var ids = VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n let labels = claudeAccountLabels()\n if let at = ids.firstIndex(of: \"anthropic\") {\n ids.insert(contentsOf: labels.map { ACCOUNT_ID_PREFIX + $0 }, at: at + 1)\n }\n return ids\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n Toggle(\"Compactar/Expandir com ⌥⌘E (atalho global)\", isOn: $compactShortcutEnabled)\n Text(\"Alterna a Visão geral entre barras e modo compacto.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Sistema\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Iniciar no login\", isOn: $launchAtLogin)\n .onChange(of: launchAtLogin) { value in setLaunchAtLogin(value) }\n Text(\"Instala um LaunchAgent que sobe o app ao entrar na sua conta.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n }\n}\n\n// ─── Launch at login (macOS LaunchAgent) ─────────────────────────────────\n//\n// The app is an unbundled single binary, so the modern SMAppService.mainApp\n// API (which needs a bundle id) doesn't apply. A per-user LaunchAgent is the\n// portable way to start an unbundled executable at login — the same mechanism\n// install-agent.sh sets up, now toggleable from Preferences.\nlet LAUNCH_AGENT_LABEL = \"com.akitaonrails.ai-usagebar-menubar\"\n\nfunc launchAgentPlistPath() -> String {\n \"\\(NSHomeDirectory())/Library/LaunchAgents/\\(LAUNCH_AGENT_LABEL).plist\"\n}\n\n/// Absolute path of the running executable, for the LaunchAgent to relaunch.\nfunc selfExecutablePath() -> String {\n if let p = Bundle.main.executablePath, !p.isEmpty { return p }\n let arg0 = CommandLine.arguments.first ?? \"ai-usagebar-menubar\"\n if arg0.hasPrefix(\"/\") { return arg0 }\n return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)\n .appendingPathComponent(arg0).standardized.path\n}\n\n/// Install (or remove) the login LaunchAgent by managing its plist file.\n/// Building the plist via PropertyListSerialization sidesteps the XML-escaping\n/// the shell installer has to do by hand.\n///\n/// Deliberately no `launchctl load`/`unload`: the app is already running when\n/// this is toggled, so `RunAtLoad` on load would spawn a *second* copy, and\n/// `unload` on disable would SIGTERM this very process (quitting the app the\n/// user is still using) if it happened to be the launchd-managed one. launchd\n/// loads every agent in ~/Library/LaunchAgents at the next login, so writing\n/// (or removing) the file is all that's needed for a start-at-login toggle.\nfunc setLaunchAtLogin(_ enabled: Bool) {\n let path = launchAgentPlistPath()\n if enabled {\n let dir = (path as NSString).deletingLastPathComponent\n try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)\n let plist: [String: Any] = [\n \"Label\": LAUNCH_AGENT_LABEL,\n \"ProgramArguments\": [selfExecutablePath()],\n \"RunAtLoad\": true,\n \"ProcessType\": \"Interactive\",\n ]\n if let data = try? PropertyListSerialization.data(\n fromPropertyList: plist, format: .xml, options: 0) {\n try? data.write(to: URL(fileURLWithPath: path))\n }\n } else {\n try? FileManager.default.removeItem(atPath: path)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\nlet compactHotKeyNotification = Notification.Name(\"aiusagebar.compactHotKey\")\n\n/// Default shortcuts: `\\` (kVK_ANSI_Backslash) with Command+Option swaps the\n/// vendor; `E` with Command+Option toggles the overview's Compactar/Expandir.\n/// `[ui]` on Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet COMPACT_HOTKEY_KEYCODE = UInt32(kVK_ANSI_E)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\n/// Registered hot-key ids, carried back to us in the event so one shared C\n/// callback can tell which shortcut fired.\nlet SWAP_HOTKEY_ID = UInt32(1)\nlet COMPACT_HOTKEY_ID = UInt32(2)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n var hk = EventHotKeyID()\n GetEventParameter(event, EventParamName(kEventParamDirectObject),\n EventParamType(typeEventHotKeyID), nil,\n MemoryLayout<EventHotKeyID>.size, nil, &hk)\n NotificationCenter.default.post(\n name: hk.id == COMPACT_HOTKEY_ID ? compactHotKeyNotification : swapHotKeyNotification,\n object: nil)\n return noErr\n}\n\n/// Whether the overview status-bar title draws mini bars (vs. the compact\n/// %-text mode). \"Compactar\" forces the text mode even under the bars-count\n/// threshold. Pure + testable — the render path is not.\nfunc overviewUsesBars(count: Int, barsMax: Int, compact: Bool) -> Bool {\n !compact && count <= barsMax\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var compactHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor); hidden in single-vendor mode.\n // A fixed pool avoids rebuilding the menu (which would disrupt an open menu).\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n /// Overview-only: forces the status-bar title into the compact %-text mode\n /// (\"Compactar\"); while compact it reads \"Expandir\" and turns it back off.\n let compactItem = NSMenuItem(title: \"Compactar\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true, \"compactShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleCompactHotKey),\n name: compactHotKeyNotification, object: nil)\n installSwapHotKey()\n installCompactHotKey()\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n removeSwapHotKey()\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n installHotKeyHandlerOnce()\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: SWAP_HOTKEY_ID) // 'AIBB'\n RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &swapHotKeyRef)\n }\n\n /// (Re)register the global ⌥⌘E Compactar/Expandir hot key to match the\n /// `compactShortcutEnabled` preference. Idempotent, mirroring\n /// `installSwapHotKey` — including the painted-in hint (a native\n /// keyEquivalent would fire a second time while the menu is open, double-\n /// toggling on one press).\n func installCompactHotKey() {\n if let ref = compactHotKeyRef {\n UnregisterEventHotKey(ref)\n compactHotKeyRef = nil\n }\n updateCompactItemTitle()\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else { return }\n installHotKeyHandlerOnce()\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: COMPACT_HOTKEY_ID)\n RegisterEventHotKey(\n COMPACT_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &compactHotKeyRef)\n }\n\n /// Compactar ↔ Expandir label plus the dimmed ⌥⌘E hint when the global\n /// shortcut is on. Shared by the overview render and the hot-key installer.\n func updateCompactItemTitle() {\n let label = DEF.bool(forKey: \"overviewCompact\") ? \"Expandir\" : \"Compactar\"\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else {\n compactItem.attributedTitle = NSAttributedString(string: label)\n return\n }\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(string: \"\\(label) \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘E\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n compactItem.attributedTitle = s\n }\n\n /// One Carbon handler serves every hot key; the event's EventHotKeyID\n /// says which one fired.\n private func installHotKeyHandlerOnce() {\n guard !swapHotKeyHandlerInstalled else { return }\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n InstallEventHandler(GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n swapHotKeyHandlerInstalled = true\n }\n\n func removeSwapHotKey() {\n if let ref = swapHotKeyRef {\n UnregisterEventHotKey(ref)\n swapHotKeyRef = nil\n }\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// ⌥⌘E: toggle Compactar/Expandir. Overview-only — flipping a hidden\n /// preference from another view would be invisible and confusing.\n @objc func handleCompactHotKey() {\n guard VENDOR == \"overview\" else { return }\n toggleCompact()\n }\n\n /// The swap-shortcut ring: every configured entry (vendors *and* Claude\n /// accounts) plus the synthetic \"overview\" target, so ⌥⌘\\ cycles them all.\n func swapCycleIds() -> [String] {\n var ids = vendorEntries(active: VENDOR).map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per possible vendor for Overview mode. ponytail: 12 ≥\n // the vendor count; bump if VENDOR_AUTH ever grows past it.\n for _ in 0..<12 {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n // First item after the usage rows, Overview-only (hidden elsewhere).\n compactItem.action = #selector(toggleCompact)\n compactItem.target = self\n compactItem.isHidden = true\n menu.addItem(compactItem)\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n /// Flip the overview's compact mode; the UserDefaults observer re-renders,\n /// which also relabels the item (Compactar ↔ Expandir).\n @objc func toggleCompact() {\n DEF.set(!DEF.bool(forKey: \"overviewCompact\"), forKey: \"overviewCompact\")\n }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n if DEF.bool(forKey: \"compactShortcutEnabled\") != (compactHotKeyRef != nil) {\n installCompactHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = entryDisplayName(VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: vendor) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n // vendorEntries(active: \"\") keeps only configured entries — the\n // active-vendor courtesy slot has no place in an all-vendors sweep.\n let entries = vendorEntries(active: \"\")\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in entries {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: v.id) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: baseVendorId(v.id))\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// Compact bar label for a vendor name. Usually the first word; for a\n /// Claude account (\"Claude · gmail\") the account label is the part that\n /// distinguishes entries, so use it instead of the shared \"Claude\".\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let base = name.range(of: \" · \").map { String(name[$0.upperBound...]) }\n ?? String(name.split(separator: \" \").first ?? Substring(name))\n return base.count <= maxLen ? base : String(base.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 4) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String)] = items.compactMap {\n guard let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\") }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\")\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 4\n let compact = DEF.bool(forKey: \"overviewCompact\")\n let t = NSMutableAttributedString()\n if overviewUsesBars(count: heads.count, barsMax: barsMax, compact: compact) {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n // Entry order, not worst-first: entries are provider-grouped\n // (standard Claude, then its accounts, then the other vendors), and\n // stable positions beat a pct sort that reshuffles labels on every\n // refresh.\n for (i, e) in heads.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n }\n if heads.count > maxN { t.append(run(\" +\\(heads.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to jump straight to that vendor's detail view.\n it.representedObject = item.id\n it.target = self\n it.action = #selector(switchVendor(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n compactItem.isHidden = false\n updateCompactItemTitle()\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: baseVendorId(VENDOR)) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n // With several Claude accounts the plan alone (\"Claude Max 20x\") no\n // longer says WHICH account this is — suffix the label.\n let plan = s.plan.isEmpty ? \"AI Usage\" : s.plan\n let header = accountLabel(of: VENDOR).map { \"\\(plan) · \\($0)\" } ?? plan\n headerItem.attributedTitle = run(header, .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let entries = vendorEntries(active: active)\n if entries.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in entries {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "5fbf8a97d256b97bfa78ab4616f4e1ae60bd4635": {"t": 1785750134.7420537, "n": 85857, "h": "1747246763dc4abf", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n// ── Anthropic multi-account ────────────────────────────────────────────────\n// Mirrors the Rust side (`src/config.rs`): explicit `[[anthropic.accounts]]`\n// entries plus subdirectories of `[anthropic] accounts_dir` holding a\n// `.credentials.json`, explicit labels winning on a clash. Each account is a\n// selectable entry with the pseudo-id `anthropic@<label>`, fetched by the\n// binary as `--vendor anthropic --account <label>` — the binary resolves the\n// account's credentials itself (file or its own Keychain item), so the app\n// never needs to know where they live.\n\n/// `[[anthropic.accounts]]` labels, in file order. Pure text scan, like the\n/// rest of this app's TOML reading: a `[[anthropic.accounts]]` header opens a\n/// block, the first `label = \"…\"` inside it counts.\nfunc anthropicAccountLabels(inTOML text: String) -> [String] {\n var labels: [String] = []\n var inBlock = false\n for rawLine in text.components(separatedBy: .newlines) {\n let line = rawLine.trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inBlock = line == \"[[anthropic.accounts]]\"\n continue\n }\n guard inBlock, line.hasPrefix(\"label\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == \"label\" else { continue }\n var value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n value = String(content[..<end])\n }\n if !value.isEmpty {\n labels.append(value)\n inBlock = false // one label per block\n }\n }\n return labels\n}\n\n/// Subdirectories of `dir` holding a `.credentials.json`, sorted — the same\n/// discovery rule as the Rust `accounts_dir` scan.\nfunc discoverAccountLabels(inDir dir: String) -> [String] {\n let fm = FileManager.default\n guard let names = try? fm.contentsOfDirectory(atPath: dir) else { return [] }\n return names.filter { name in\n var isDir: ObjCBool = false\n let sub = \"\\(dir)/\\(name)\"\n return fm.fileExists(atPath: sub, isDirectory: &isDir) && isDir.boolValue\n && fm.fileExists(atPath: \"\\(sub)/.credentials.json\")\n }.sorted()\n}\n\n/// Explicit labels first (file order), then discovered ones that don't clash.\nfunc mergedAccountLabels(explicit: [String], discovered: [String]) -> [String] {\n explicit + discovered.filter { !explicit.contains($0) }\n}\n\n/// All configured Claude account labels, from the same config the binary reads.\nfunc claudeAccountLabels() -> [String] {\n guard let text = try? String(contentsOfFile: configPathTOML(), encoding: .utf8) else {\n return []\n }\n let explicit = anthropicAccountLabels(inTOML: text)\n var discovered: [String] = []\n if let dir = tomlValueInText(text, section: \"anthropic\", key: \"accounts_dir\"), !dir.isEmpty {\n discovered = discoverAccountLabels(inDir: NSString(string: dir).expandingTildeInPath)\n }\n return mergedAccountLabels(explicit: explicit, discovered: discovered)\n}\n\n/// Rust semantics (`show_default_account`): `false` hides the default\n/// (unnamed) Claude entry, but is ignored when there are no named accounts,\n/// so Anthropic never loses its only entry.\nfunc showDefaultClaudeAccount(configValue: String?, hasAccounts: Bool) -> Bool {\n guard hasAccounts else { return true }\n return configValue != \"false\"\n}\n\n/// Pseudo-id mapping: `anthropic@<label>` selects a named account.\nlet ACCOUNT_ID_PREFIX = \"anthropic@\"\n\nfunc accountLabel(of id: String) -> String? {\n id.hasPrefix(ACCOUNT_ID_PREFIX) ? String(id.dropFirst(ACCOUNT_ID_PREFIX.count)) : nil\n}\n\nfunc baseVendorId(_ id: String) -> String {\n accountLabel(of: id) != nil ? \"anthropic\" : id\n}\n\n/// The subprocess arguments that select `id` (base vendor or named account).\nfunc vendorArgs(for id: String) -> [String] {\n if let label = accountLabel(of: id) {\n return [\"--vendor\", \"anthropic\", \"--account\", label]\n }\n return [\"--vendor\", id]\n}\n\n/// One selectable entry: a base vendor or one Claude account.\nstruct MenuEntry {\n let id: String // \"cursor\", \"anthropic\", or \"anthropic@<label>\"\n let name: String // display: \"Cursor\", \"Claude · <label>\"\n}\n\n/// The selectable entries, in menu order: every enabled+configured vendor,\n/// with Anthropic expanded into its named accounts (the default entry kept\n/// only per `show_default_account`). `active` stays listed even when\n/// unconfigured — same rule the per-vendor list always had.\nfunc vendorEntries(active: String) -> [MenuEntry] {\n var out: [MenuEntry] = []\n for v in VENDOR_AUTH where vendorEnabled(v) {\n if v.id == \"anthropic\" {\n let labels = claudeAccountLabels()\n let showDefault = showDefaultClaudeAccount(\n configValue: configValueTOML(\"anthropic\", \"show_default_account\"),\n hasAccounts: !labels.isEmpty)\n if showDefault && (v.id == active || vendorConfigured(v)) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n for label in labels {\n out.append(MenuEntry(id: ACCOUNT_ID_PREFIX + label, name: \"Claude · \\(label)\"))\n }\n } else if v.id == active || vendorConfigured(v) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n }\n return out\n}\n\n/// Display name for any selectable id (base vendor, account, or overview).\nfunc entryDisplayName(_ id: String) -> String {\n if id == \"overview\" { return \"Visão geral\" }\n if let label = accountLabel(of: id) { return \"Claude · \\(label)\" }\n return VENDOR_AUTH.first { $0.id == id }?.name ?? id\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"compactShortcutEnabled\") private var compactShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker. Claude\n // accounts appear as their `anthropic@<label>` pseudo-ids, same as the\n // \"Trocar vendor\" submenu.\n private var vendors: [String] {\n var ids = VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n let labels = claudeAccountLabels()\n if let at = ids.firstIndex(of: \"anthropic\") {\n ids.insert(contentsOf: labels.map { ACCOUNT_ID_PREFIX + $0 }, at: at + 1)\n }\n return ids\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n Toggle(\"Compactar/Expandir com ⌥⌘E (atalho global)\", isOn: $compactShortcutEnabled)\n Text(\"Alterna a Visão geral entre barras e modo compacto.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\nlet compactHotKeyNotification = Notification.Name(\"aiusagebar.compactHotKey\")\n\n/// Default shortcuts: `\\` (kVK_ANSI_Backslash) with Command+Option swaps the\n/// vendor; `E` with Command+Option toggles the overview's Compactar/Expandir.\n/// `[ui]` on Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet COMPACT_HOTKEY_KEYCODE = UInt32(kVK_ANSI_E)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\n/// Registered hot-key ids, carried back to us in the event so one shared C\n/// callback can tell which shortcut fired.\nlet SWAP_HOTKEY_ID = UInt32(1)\nlet COMPACT_HOTKEY_ID = UInt32(2)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n var hk = EventHotKeyID()\n GetEventParameter(event, EventParamName(kEventParamDirectObject),\n EventParamType(typeEventHotKeyID), nil,\n MemoryLayout<EventHotKeyID>.size, nil, &hk)\n NotificationCenter.default.post(\n name: hk.id == COMPACT_HOTKEY_ID ? compactHotKeyNotification : swapHotKeyNotification,\n object: nil)\n return noErr\n}\n\n/// Whether the overview status-bar title draws mini bars (vs. the compact\n/// %-text mode). \"Compactar\" forces the text mode even under the bars-count\n/// threshold. Pure + testable — the render path is not.\nfunc overviewUsesBars(count: Int, barsMax: Int, compact: Bool) -> Bool {\n !compact && count <= barsMax\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var compactHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor); hidden in single-vendor mode.\n // A fixed pool avoids rebuilding the menu (which would disrupt an open menu).\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n /// Overview-only: forces the status-bar title into the compact %-text mode\n /// (\"Compactar\"); while compact it reads \"Expandir\" and turns it back off.\n let compactItem = NSMenuItem(title: \"Compactar\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true, \"compactShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleCompactHotKey),\n name: compactHotKeyNotification, object: nil)\n installSwapHotKey()\n installCompactHotKey()\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n removeSwapHotKey()\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n installHotKeyHandlerOnce()\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: SWAP_HOTKEY_ID) // 'AIBB'\n RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &swapHotKeyRef)\n }\n\n /// (Re)register the global ⌥⌘E Compactar/Expandir hot key to match the\n /// `compactShortcutEnabled` preference. Idempotent, mirroring\n /// `installSwapHotKey` — including the painted-in hint (a native\n /// keyEquivalent would fire a second time while the menu is open, double-\n /// toggling on one press).\n func installCompactHotKey() {\n if let ref = compactHotKeyRef {\n UnregisterEventHotKey(ref)\n compactHotKeyRef = nil\n }\n updateCompactItemTitle()\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else { return }\n installHotKeyHandlerOnce()\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: COMPACT_HOTKEY_ID)\n RegisterEventHotKey(\n COMPACT_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &compactHotKeyRef)\n }\n\n /// Compactar ↔ Expandir label plus the dimmed ⌥⌘E hint when the global\n /// shortcut is on. Shared by the overview render and the hot-key installer.\n func updateCompactItemTitle() {\n let label = DEF.bool(forKey: \"overviewCompact\") ? \"Expandir\" : \"Compactar\"\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else {\n compactItem.attributedTitle = NSAttributedString(string: label)\n return\n }\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(string: \"\\(label) \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘E\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n compactItem.attributedTitle = s\n }\n\n /// One Carbon handler serves every hot key; the event's EventHotKeyID\n /// says which one fired.\n private func installHotKeyHandlerOnce() {\n guard !swapHotKeyHandlerInstalled else { return }\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n InstallEventHandler(GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n swapHotKeyHandlerInstalled = true\n }\n\n func removeSwapHotKey() {\n if let ref = swapHotKeyRef {\n UnregisterEventHotKey(ref)\n swapHotKeyRef = nil\n }\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// ⌥⌘E: toggle Compactar/Expandir. Overview-only — flipping a hidden\n /// preference from another view would be invisible and confusing.\n @objc func handleCompactHotKey() {\n guard VENDOR == \"overview\" else { return }\n toggleCompact()\n }\n\n /// The swap-shortcut ring: every configured entry (vendors *and* Claude\n /// accounts) plus the synthetic \"overview\" target, so ⌥⌘\\ cycles them all.\n func swapCycleIds() -> [String] {\n var ids = vendorEntries(active: VENDOR).map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per possible vendor for Overview mode. ponytail: 12 ≥\n // the vendor count; bump if VENDOR_AUTH ever grows past it.\n for _ in 0..<12 {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n // First item after the usage rows, Overview-only (hidden elsewhere).\n compactItem.action = #selector(toggleCompact)\n compactItem.target = self\n compactItem.isHidden = true\n menu.addItem(compactItem)\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n /// Flip the overview's compact mode; the UserDefaults observer re-renders,\n /// which also relabels the item (Compactar ↔ Expandir).\n @objc func toggleCompact() {\n DEF.set(!DEF.bool(forKey: \"overviewCompact\"), forKey: \"overviewCompact\")\n }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n if DEF.bool(forKey: \"compactShortcutEnabled\") != (compactHotKeyRef != nil) {\n installCompactHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = entryDisplayName(VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: vendor) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n // vendorEntries(active: \"\") keeps only configured entries — the\n // active-vendor courtesy slot has no place in an all-vendors sweep.\n let entries = vendorEntries(active: \"\")\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in entries {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: v.id) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: baseVendorId(v.id))\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// Compact bar label for a vendor name. Usually the first word; for a\n /// Claude account (\"Claude · gmail\") the account label is the part that\n /// distinguishes entries, so use it instead of the shared \"Claude\".\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let base = name.range(of: \" · \").map { String(name[$0.upperBound...]) }\n ?? String(name.split(separator: \" \").first ?? Substring(name))\n return base.count <= maxLen ? base : String(base.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 4) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String)] = items.compactMap {\n guard let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\") }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\")\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 4\n let compact = DEF.bool(forKey: \"overviewCompact\")\n let t = NSMutableAttributedString()\n if overviewUsesBars(count: heads.count, barsMax: barsMax, compact: compact) {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n // Entry order, not worst-first: entries are provider-grouped\n // (standard Claude, then its accounts, then the other vendors), and\n // stable positions beat a pct sort that reshuffles labels on every\n // refresh.\n for (i, e) in heads.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n }\n if heads.count > maxN { t.append(run(\" +\\(heads.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to jump straight to that vendor's detail view.\n it.representedObject = item.id\n it.target = self\n it.action = #selector(switchVendor(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n compactItem.isHidden = false\n updateCompactItemTitle()\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: baseVendorId(VENDOR)) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n // With several Claude accounts the plan alone (\"Claude Max 20x\") no\n // longer says WHICH account this is — suffix the label.\n let plan = s.plan.isEmpty ? \"AI Usage\" : s.plan\n let header = accountLabel(of: VENDOR).map { \"\\(plan) · \\($0)\" } ?? plan\n headerItem.attributedTitle = run(header, .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let entries = vendorEntries(active: active)\n if entries.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in entries {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "09404d46d1ef24757a7e039bade922e6f6fa10fd": {"t": 1785750136.2900364, "n": 79322, "h": "868846cdbc0559d3", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"compactShortcutEnabled\") private var compactShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker.\n private var vendors: [String] {\n VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n Toggle(\"Compactar/Expandir com ⌥⌘E (atalho global)\", isOn: $compactShortcutEnabled)\n Text(\"Alterna a Visão geral entre barras e modo compacto.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\nlet compactHotKeyNotification = Notification.Name(\"aiusagebar.compactHotKey\")\n\n/// Default shortcuts: `\\` (kVK_ANSI_Backslash) with Command+Option swaps the\n/// vendor; `E` with Command+Option toggles the overview's Compactar/Expandir.\n/// `[ui]` on Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet COMPACT_HOTKEY_KEYCODE = UInt32(kVK_ANSI_E)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\n/// Registered hot-key ids, carried back to us in the event so one shared C\n/// callback can tell which shortcut fired.\nlet SWAP_HOTKEY_ID = UInt32(1)\nlet COMPACT_HOTKEY_ID = UInt32(2)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n var hk = EventHotKeyID()\n GetEventParameter(event, EventParamName(kEventParamDirectObject),\n EventParamType(typeEventHotKeyID), nil,\n MemoryLayout<EventHotKeyID>.size, nil, &hk)\n NotificationCenter.default.post(\n name: hk.id == COMPACT_HOTKEY_ID ? compactHotKeyNotification : swapHotKeyNotification,\n object: nil)\n return noErr\n}\n\n/// Whether the overview status-bar title draws mini bars (vs. the compact\n/// %-text mode). \"Compactar\" forces the text mode even under the bars-count\n/// threshold. Pure + testable — the render path is not.\nfunc overviewUsesBars(count: Int, barsMax: Int, compact: Bool) -> Bool {\n !compact && count <= barsMax\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var compactHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor); hidden in single-vendor mode.\n // A fixed pool avoids rebuilding the menu (which would disrupt an open menu).\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n /// Overview-only: forces the status-bar title into the compact %-text mode\n /// (\"Compactar\"); while compact it reads \"Expandir\" and turns it back off.\n let compactItem = NSMenuItem(title: \"Compactar\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true, \"compactShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleCompactHotKey),\n name: compactHotKeyNotification, object: nil)\n installSwapHotKey()\n installCompactHotKey()\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n removeSwapHotKey()\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n installHotKeyHandlerOnce()\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: SWAP_HOTKEY_ID) // 'AIBB'\n RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &swapHotKeyRef)\n }\n\n /// (Re)register the global ⌥⌘E Compactar/Expandir hot key to match the\n /// `compactShortcutEnabled` preference. Idempotent, mirroring\n /// `installSwapHotKey` — including the painted-in hint (a native\n /// keyEquivalent would fire a second time while the menu is open, double-\n /// toggling on one press).\n func installCompactHotKey() {\n if let ref = compactHotKeyRef {\n UnregisterEventHotKey(ref)\n compactHotKeyRef = nil\n }\n updateCompactItemTitle()\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else { return }\n installHotKeyHandlerOnce()\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: COMPACT_HOTKEY_ID)\n RegisterEventHotKey(\n COMPACT_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &compactHotKeyRef)\n }\n\n /// Compactar ↔ Expandir label plus the dimmed ⌥⌘E hint when the global\n /// shortcut is on. Shared by the overview render and the hot-key installer.\n func updateCompactItemTitle() {\n let label = DEF.bool(forKey: \"overviewCompact\") ? \"Expandir\" : \"Compactar\"\n guard DEF.bool(forKey: \"compactShortcutEnabled\") else {\n compactItem.attributedTitle = NSAttributedString(string: label)\n return\n }\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(string: \"\\(label) \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘E\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n compactItem.attributedTitle = s\n }\n\n /// One Carbon handler serves every hot key; the event's EventHotKeyID\n /// says which one fired.\n private func installHotKeyHandlerOnce() {\n guard !swapHotKeyHandlerInstalled else { return }\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n InstallEventHandler(GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n swapHotKeyHandlerInstalled = true\n }\n\n func removeSwapHotKey() {\n if let ref = swapHotKeyRef {\n UnregisterEventHotKey(ref)\n swapHotKeyRef = nil\n }\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// ⌥⌘E: toggle Compactar/Expandir. Overview-only — flipping a hidden\n /// preference from another view would be invisible and confusing.\n @objc func handleCompactHotKey() {\n guard VENDOR == \"overview\" else { return }\n toggleCompact()\n }\n\n /// The swap-shortcut ring: every configured vendor plus the synthetic\n /// \"overview\" target, so ⌥⌘\\ cycles all providers *and* the overview.\n func swapCycleIds() -> [String] {\n var ids = VENDOR_AUTH\n .filter { vendorEnabled($0) && ($0.id == VENDOR || vendorConfigured($0)) }\n .map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per possible vendor for Overview mode. ponytail: 12 ≥\n // the vendor count; bump if VENDOR_AUTH ever grows past it.\n for _ in 0..<12 {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n // First item after the usage rows, Overview-only (hidden elsewhere).\n compactItem.action = #selector(toggleCompact)\n compactItem.target = self\n compactItem.isHidden = true\n menu.addItem(compactItem)\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n /// Flip the overview's compact mode; the UserDefaults observer re-renders,\n /// which also relabels the item (Compactar ↔ Expandir).\n @objc func toggleCompact() {\n DEF.set(!DEF.bool(forKey: \"overviewCompact\"), forKey: \"overviewCompact\")\n }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n if DEF.bool(forKey: \"compactShortcutEnabled\") != (compactHotKeyRef != nil) {\n installCompactHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = VENDOR == \"overview\"\n ? \"Visão geral\"\n : (VENDOR_AUTH.first { $0.id == VENDOR }?.name ?? VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = [\"--vendor\", vendor, \"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n let vendors = VENDOR_AUTH.filter { vendorEnabled($0) && vendorConfigured($0) }\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in vendors {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = [\"--vendor\", v.id, \"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: v.id)\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// First word of a vendor name, clipped to `maxLen` — a compact bar label.\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let first = String(name.split(separator: \" \").first ?? Substring(name))\n return first.count <= maxLen ? first : String(first.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 4) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String)] = items.compactMap {\n guard let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\") }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\")\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 4\n let compact = DEF.bool(forKey: \"overviewCompact\")\n let t = NSMutableAttributedString()\n if overviewUsesBars(count: heads.count, barsMax: barsMax, compact: compact) {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n // Entry order, not worst-first: entries are provider-grouped\n // (standard Claude, then its accounts, then the other vendors), and\n // stable positions beat a pct sort that reshuffles labels on every\n // refresh.\n for (i, e) in heads.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n }\n if heads.count > maxN { t.append(run(\" +\\(heads.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to jump straight to that vendor's detail view.\n it.representedObject = item.id\n it.target = self\n it.action = #selector(switchVendor(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n compactItem.isHidden = false\n updateCompactItemTitle()\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: VENDOR) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n headerItem.attributedTitle = run(s.plan.isEmpty ? \"AI Usage\" : s.plan,\n .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let configured = VENDOR_AUTH.filter { vendorEnabled($0) && ($0.id == active || vendorConfigured($0)) }\n if configured.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in configured {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "b63e6c34e085bcc42586ff63cefbed1f896399d4": {"t": 1785750138.0516062, "n": 85366, "h": "65d2bb6ad24e4f54", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n// ── Anthropic multi-account ────────────────────────────────────────────────\n// Mirrors the Rust side (`src/config.rs`): explicit `[[anthropic.accounts]]`\n// entries plus subdirectories of `[anthropic] accounts_dir` holding a\n// `.credentials.json`, explicit labels winning on a clash. Each account is a\n// selectable entry with the pseudo-id `anthropic@<label>`, fetched by the\n// binary as `--vendor anthropic --account <label>` — the binary resolves the\n// account's credentials itself (file or its own Keychain item), so the app\n// never needs to know where they live.\n\n/// `[[anthropic.accounts]]` labels, in file order. Pure text scan, like the\n/// rest of this app's TOML reading: a `[[anthropic.accounts]]` header opens a\n/// block, the first `label = \"…\"` inside it counts.\nfunc anthropicAccountLabels(inTOML text: String) -> [String] {\n var labels: [String] = []\n var inBlock = false\n for rawLine in text.components(separatedBy: .newlines) {\n let line = rawLine.trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inBlock = line == \"[[anthropic.accounts]]\"\n continue\n }\n guard inBlock, line.hasPrefix(\"label\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == \"label\" else { continue }\n var value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n value = String(content[..<end])\n }\n if !value.isEmpty {\n labels.append(value)\n inBlock = false // one label per block\n }\n }\n return labels\n}\n\n/// Subdirectories of `dir` holding a `.credentials.json`, sorted — the same\n/// discovery rule as the Rust `accounts_dir` scan.\nfunc discoverAccountLabels(inDir dir: String) -> [String] {\n let fm = FileManager.default\n guard let names = try? fm.contentsOfDirectory(atPath: dir) else { return [] }\n return names.filter { name in\n var isDir: ObjCBool = false\n let sub = \"\\(dir)/\\(name)\"\n return fm.fileExists(atPath: sub, isDirectory: &isDir) && isDir.boolValue\n && fm.fileExists(atPath: \"\\(sub)/.credentials.json\")\n }.sorted()\n}\n\n/// Explicit labels first (file order), then discovered ones that don't clash.\nfunc mergedAccountLabels(explicit: [String], discovered: [String]) -> [String] {\n explicit + discovered.filter { !explicit.contains($0) }\n}\n\n/// All configured Claude account labels, from the same config the binary reads.\nfunc claudeAccountLabels() -> [String] {\n guard let text = try? String(contentsOfFile: configPathTOML(), encoding: .utf8) else {\n return []\n }\n let explicit = anthropicAccountLabels(inTOML: text)\n var discovered: [String] = []\n if let dir = tomlValueInText(text, section: \"anthropic\", key: \"accounts_dir\"), !dir.isEmpty {\n discovered = discoverAccountLabels(inDir: NSString(string: dir).expandingTildeInPath)\n }\n return mergedAccountLabels(explicit: explicit, discovered: discovered)\n}\n\n/// Rust semantics (`show_default_account`): `false` hides the default\n/// (unnamed) Claude entry, but is ignored when there are no named accounts,\n/// so Anthropic never loses its only entry.\nfunc showDefaultClaudeAccount(configValue: String?, hasAccounts: Bool) -> Bool {\n guard hasAccounts else { return true }\n return configValue != \"false\"\n}\n\n/// Pseudo-id mapping: `anthropic@<label>` selects a named account.\nlet ACCOUNT_ID_PREFIX = \"anthropic@\"\n\nfunc accountLabel(of id: String) -> String? {\n id.hasPrefix(ACCOUNT_ID_PREFIX) ? String(id.dropFirst(ACCOUNT_ID_PREFIX.count)) : nil\n}\n\nfunc baseVendorId(_ id: String) -> String {\n accountLabel(of: id) != nil ? \"anthropic\" : id\n}\n\n/// The subprocess arguments that select `id` (base vendor or named account).\nfunc vendorArgs(for id: String) -> [String] {\n if let label = accountLabel(of: id) {\n return [\"--vendor\", \"anthropic\", \"--account\", label]\n }\n return [\"--vendor\", id]\n}\n\n/// One selectable entry: a base vendor or one Claude account.\nstruct MenuEntry {\n let id: String // \"cursor\", \"anthropic\", or \"anthropic@<label>\"\n let name: String // display: \"Cursor\", \"Claude · <label>\"\n}\n\n/// The selectable entries, in menu order: every enabled+configured vendor,\n/// with Anthropic expanded into its named accounts (the default entry kept\n/// only per `show_default_account`). `active` stays listed even when\n/// unconfigured — same rule the per-vendor list always had.\nfunc vendorEntries(active: String) -> [MenuEntry] {\n var out: [MenuEntry] = []\n for v in VENDOR_AUTH where vendorEnabled(v) {\n if v.id == \"anthropic\" {\n let labels = claudeAccountLabels()\n let showDefault = showDefaultClaudeAccount(\n configValue: configValueTOML(\"anthropic\", \"show_default_account\"),\n hasAccounts: !labels.isEmpty)\n if showDefault && (v.id == active || vendorConfigured(v)) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n for label in labels {\n out.append(MenuEntry(id: ACCOUNT_ID_PREFIX + label, name: \"Claude · \\(label)\"))\n }\n } else if v.id == active || vendorConfigured(v) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n }\n return out\n}\n\n/// Display name for any selectable id (base vendor, account, or overview).\nfunc entryDisplayName(_ id: String) -> String {\n if id == \"overview\" { return \"Visão geral\" }\n if let label = accountLabel(of: id) { return \"Claude · \\(label)\" }\n return VENDOR_AUTH.first { $0.id == id }?.name ?? id\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n @AppStorage(\"launchAtLogin\") private var launchAtLogin = false\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker. Claude\n // accounts appear as their `anthropic@<label>` pseudo-ids, same as the\n // \"Trocar vendor\" submenu.\n private var vendors: [String] {\n var ids = VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n let labels = claudeAccountLabels()\n if let at = ids.firstIndex(of: \"anthropic\") {\n ids.insert(contentsOf: labels.map { ACCOUNT_ID_PREFIX + $0 }, at: at + 1)\n }\n return ids\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Sistema\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Iniciar no login\", isOn: $launchAtLogin)\n .onChange(of: launchAtLogin) { value in setLaunchAtLogin(value) }\n Text(\"Instala um LaunchAgent que sobe o app ao entrar na sua conta.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n }\n}\n\n// ─── Launch at login (macOS LaunchAgent) ─────────────────────────────────\n//\n// The app is an unbundled single binary, so the modern SMAppService.mainApp\n// API (which needs a bundle id) doesn't apply. A per-user LaunchAgent is the\n// portable way to start an unbundled executable at login — the same mechanism\n// install-agent.sh sets up, now toggleable from Preferences.\nlet LAUNCH_AGENT_LABEL = \"com.akitaonrails.ai-usagebar-menubar\"\n\nfunc launchAgentPlistPath() -> String {\n \"\\(NSHomeDirectory())/Library/LaunchAgents/\\(LAUNCH_AGENT_LABEL).plist\"\n}\n\n/// Absolute path of the running executable, for the LaunchAgent to relaunch.\nfunc selfExecutablePath() -> String {\n if let p = Bundle.main.executablePath, !p.isEmpty { return p }\n let arg0 = CommandLine.arguments.first ?? \"ai-usagebar-menubar\"\n if arg0.hasPrefix(\"/\") { return arg0 }\n return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)\n .appendingPathComponent(arg0).standardized.path\n}\n\n/// Install (or remove) the login LaunchAgent by managing its plist file.\n/// Building the plist via PropertyListSerialization sidesteps the XML-escaping\n/// the shell installer has to do by hand.\n///\n/// Deliberately no `launchctl load`/`unload`: the app is already running when\n/// this is toggled, so `RunAtLoad` on load would spawn a *second* copy, and\n/// `unload` on disable would SIGTERM this very process (quitting the app the\n/// user is still using) if it happened to be the launchd-managed one. launchd\n/// loads every agent in ~/Library/LaunchAgents at the next login, so writing\n/// (or removing) the file is all that's needed for a start-at-login toggle.\nfunc setLaunchAtLogin(_ enabled: Bool) {\n let path = launchAgentPlistPath()\n if enabled {\n let dir = (path as NSString).deletingLastPathComponent\n try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)\n let plist: [String: Any] = [\n \"Label\": LAUNCH_AGENT_LABEL,\n \"ProgramArguments\": [selfExecutablePath()],\n \"RunAtLoad\": true,\n \"ProcessType\": \"Interactive\",\n ]\n if let data = try? PropertyListSerialization.data(\n fromPropertyList: plist, format: .xml, options: 0) {\n try? data.write(to: URL(fileURLWithPath: path))\n }\n } else {\n try? FileManager.default.removeItem(atPath: path)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\n\n/// Default shortcut: `\\` (kVK_ANSI_Backslash) with Command+Option. `[ui]` on\n/// Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n NotificationCenter.default.post(name: swapHotKeyNotification, object: nil)\n return noErr\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\n/// Whether the overview status-bar title draws mini bars (vs. the compact\n/// %-text mode). \"Compactar\" forces the text mode even under the bars-count\n/// threshold. Pure + testable — the render path is not.\nfunc overviewUsesBars(count: Int, barsMax: Int, compact: Bool) -> Bool {\n !compact && count <= barsMax\n}\n\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor); hidden in single-vendor mode.\n // A fixed pool avoids rebuilding the menu (which would disrupt an open menu).\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n /// Overview-only: forces the status-bar title into the compact %-text mode\n /// (\"Compactar\"); while compact it reads \"Expandir\" and turns it back off.\n let compactItem = NSMenuItem(title: \"Compactar\", action: nil, keyEquivalent: \"e\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n installSwapHotKey()\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n removeSwapHotKey()\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n if !swapHotKeyHandlerInstalled {\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n InstallEventHandler(GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n swapHotKeyHandlerInstalled = true\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: 1) // 'AIBB'\n RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &swapHotKeyRef)\n }\n\n func removeSwapHotKey() {\n if let ref = swapHotKeyRef {\n UnregisterEventHotKey(ref)\n swapHotKeyRef = nil\n }\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// The swap-shortcut ring: every configured entry (vendors *and* Claude\n /// accounts) plus the synthetic \"overview\" target, so ⌥⌘\\ cycles them all.\n func swapCycleIds() -> [String] {\n var ids = vendorEntries(active: VENDOR).map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per possible vendor for Overview mode. ponytail: 12 ≥\n // the vendor count; bump if VENDOR_AUTH ever grows past it.\n for _ in 0..<12 {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n // First item after the usage rows, Overview-only (hidden elsewhere).\n compactItem.action = #selector(toggleCompact)\n compactItem.target = self\n compactItem.isHidden = true\n menu.addItem(compactItem)\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n /// Flip the overview's compact mode; the UserDefaults observer re-renders,\n /// which also relabels the item (Compactar ↔ Expandir).\n @objc func toggleCompact() {\n DEF.set(!DEF.bool(forKey: \"overviewCompact\"), forKey: \"overviewCompact\")\n }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = entryDisplayName(VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: vendor) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n // vendorEntries(active: \"\") keeps only configured entries — the\n // active-vendor courtesy slot has no place in an all-vendors sweep.\n let entries = vendorEntries(active: \"\")\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in entries {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: v.id) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: baseVendorId(v.id))\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// Compact bar label for a vendor name. Usually the first word; for a\n /// Claude account (\"Claude · gmail\") the account label is the part that\n /// distinguishes entries, so use it instead of the shared \"Claude\".\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let base = name.range(of: \" · \").map { String(name[$0.upperBound...]) }\n ?? String(name.split(separator: \" \").first ?? Substring(name))\n return base.count <= maxLen ? base : String(base.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 4) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String)] = items.compactMap {\n guard let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\") }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\")\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 4\n let compact = DEF.bool(forKey: \"overviewCompact\")\n let t = NSMutableAttributedString()\n if overviewUsesBars(count: heads.count, barsMax: barsMax, compact: compact) {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n // Entry order, not worst-first: entries are provider-grouped\n // (standard Claude, then its accounts, then the other vendors), and\n // stable positions beat a pct sort that reshuffles labels on every\n // refresh.\n for (i, e) in heads.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n }\n if heads.count > maxN { t.append(run(\" +\\(heads.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to jump straight to that vendor's detail view.\n it.representedObject = item.id\n it.target = self\n it.action = #selector(switchVendor(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n compactItem.isHidden = false\n compactItem.title = DEF.bool(forKey: \"overviewCompact\") ? \"Expandir\" : \"Compactar\"\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: baseVendorId(VENDOR)) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n // With several Claude accounts the plan alone (\"Claude Max 20x\") no\n // longer says WHICH account this is — suffix the label.\n let plan = s.plan.isEmpty ? \"AI Usage\" : s.plan\n let header = accountLabel(of: VENDOR).map { \"\\(plan) · \\($0)\" } ?? plan\n headerItem.attributedTitle = run(header, .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let entries = vendorEntries(active: active)\n if entries.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in entries {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "104a30e7ac5fe26d442af0c08bab5f7224b6cf4b": {"t": 1785750139.769244, "n": 82354, "h": "81b4fa2486bcaea6", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n// ── Anthropic multi-account ────────────────────────────────────────────────\n// Mirrors the Rust side (`src/config.rs`): explicit `[[anthropic.accounts]]`\n// entries plus subdirectories of `[anthropic] accounts_dir` holding a\n// `.credentials.json`, explicit labels winning on a clash. Each account is a\n// selectable entry with the pseudo-id `anthropic@<label>`, fetched by the\n// binary as `--vendor anthropic --account <label>` — the binary resolves the\n// account's credentials itself (file or its own Keychain item), so the app\n// never needs to know where they live.\n\n/// `[[anthropic.accounts]]` labels, in file order. Pure text scan, like the\n/// rest of this app's TOML reading: a `[[anthropic.accounts]]` header opens a\n/// block, the first `label = \"…\"` inside it counts.\nfunc anthropicAccountLabels(inTOML text: String) -> [String] {\n var labels: [String] = []\n var inBlock = false\n for rawLine in text.components(separatedBy: .newlines) {\n let line = rawLine.trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inBlock = line == \"[[anthropic.accounts]]\"\n continue\n }\n guard inBlock, line.hasPrefix(\"label\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == \"label\" else { continue }\n var value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n value = String(content[..<end])\n }\n if !value.isEmpty {\n labels.append(value)\n inBlock = false // one label per block\n }\n }\n return labels\n}\n\n/// Subdirectories of `dir` holding a `.credentials.json`, sorted — the same\n/// discovery rule as the Rust `accounts_dir` scan.\nfunc discoverAccountLabels(inDir dir: String) -> [String] {\n let fm = FileManager.default\n guard let names = try? fm.contentsOfDirectory(atPath: dir) else { return [] }\n return names.filter { name in\n var isDir: ObjCBool = false\n let sub = \"\\(dir)/\\(name)\"\n return fm.fileExists(atPath: sub, isDirectory: &isDir) && isDir.boolValue\n && fm.fileExists(atPath: \"\\(sub)/.credentials.json\")\n }.sorted()\n}\n\n/// Explicit labels first (file order), then discovered ones that don't clash.\nfunc mergedAccountLabels(explicit: [String], discovered: [String]) -> [String] {\n explicit + discovered.filter { !explicit.contains($0) }\n}\n\n/// All configured Claude account labels, from the same config the binary reads.\nfunc claudeAccountLabels() -> [String] {\n guard let text = try? String(contentsOfFile: configPathTOML(), encoding: .utf8) else {\n return []\n }\n let explicit = anthropicAccountLabels(inTOML: text)\n var discovered: [String] = []\n if let dir = tomlValueInText(text, section: \"anthropic\", key: \"accounts_dir\"), !dir.isEmpty {\n discovered = discoverAccountLabels(inDir: NSString(string: dir).expandingTildeInPath)\n }\n return mergedAccountLabels(explicit: explicit, discovered: discovered)\n}\n\n/// Rust semantics (`show_default_account`): `false` hides the default\n/// (unnamed) Claude entry, but is ignored when there are no named accounts,\n/// so Anthropic never loses its only entry.\nfunc showDefaultClaudeAccount(configValue: String?, hasAccounts: Bool) -> Bool {\n guard hasAccounts else { return true }\n return configValue != \"false\"\n}\n\n/// Pseudo-id mapping: `anthropic@<label>` selects a named account.\nlet ACCOUNT_ID_PREFIX = \"anthropic@\"\n\nfunc accountLabel(of id: String) -> String? {\n id.hasPrefix(ACCOUNT_ID_PREFIX) ? String(id.dropFirst(ACCOUNT_ID_PREFIX.count)) : nil\n}\n\nfunc baseVendorId(_ id: String) -> String {\n accountLabel(of: id) != nil ? \"anthropic\" : id\n}\n\n/// The subprocess arguments that select `id` (base vendor or named account).\nfunc vendorArgs(for id: String) -> [String] {\n if let label = accountLabel(of: id) {\n return [\"--vendor\", \"anthropic\", \"--account\", label]\n }\n return [\"--vendor\", id]\n}\n\n/// One selectable entry: a base vendor or one Claude account.\nstruct MenuEntry {\n let id: String // \"cursor\", \"anthropic\", or \"anthropic@<label>\"\n let name: String // display: \"Cursor\", \"Claude · <label>\"\n}\n\n/// The selectable entries, in menu order: every enabled+configured vendor,\n/// with Anthropic expanded into its named accounts (the default entry kept\n/// only per `show_default_account`). `active` stays listed even when\n/// unconfigured — same rule the per-vendor list always had.\nfunc vendorEntries(active: String) -> [MenuEntry] {\n var out: [MenuEntry] = []\n for v in VENDOR_AUTH where vendorEnabled(v) {\n if v.id == \"anthropic\" {\n let labels = claudeAccountLabels()\n let showDefault = showDefaultClaudeAccount(\n configValue: configValueTOML(\"anthropic\", \"show_default_account\"),\n hasAccounts: !labels.isEmpty)\n if showDefault && (v.id == active || vendorConfigured(v)) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n for label in labels {\n out.append(MenuEntry(id: ACCOUNT_ID_PREFIX + label, name: \"Claude · \\(label)\"))\n }\n } else if v.id == active || vendorConfigured(v) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n }\n return out\n}\n\n/// Display name for any selectable id (base vendor, account, or overview).\nfunc entryDisplayName(_ id: String) -> String {\n if id == \"overview\" { return \"Visão geral\" }\n if let label = accountLabel(of: id) { return \"Claude · \\(label)\" }\n return VENDOR_AUTH.first { $0.id == id }?.name ?? id\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker. Claude\n // accounts appear as their `anthropic@<label>` pseudo-ids, same as the\n // \"Trocar vendor\" submenu.\n private var vendors: [String] {\n var ids = VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n let labels = claudeAccountLabels()\n if let at = ids.firstIndex(of: \"anthropic\") {\n ids.insert(contentsOf: labels.map { ACCOUNT_ID_PREFIX + $0 }, at: at + 1)\n }\n return ids\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\n\n/// Default shortcut: `\\` (kVK_ANSI_Backslash) with Command+Option. `[ui]` on\n/// Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n NotificationCenter.default.post(name: swapHotKeyNotification, object: nil)\n return noErr\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\n/// Whether the overview status-bar title draws mini bars (vs. the compact\n/// %-text mode). \"Compactar\" forces the text mode even under the bars-count\n/// threshold. Pure + testable — the render path is not.\nfunc overviewUsesBars(count: Int, barsMax: Int, compact: Bool) -> Bool {\n !compact && count <= barsMax\n}\n\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor); hidden in single-vendor mode.\n // A fixed pool avoids rebuilding the menu (which would disrupt an open menu).\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n /// Overview-only: forces the status-bar title into the compact %-text mode\n /// (\"Compactar\"); while compact it reads \"Expandir\" and turns it back off.\n let compactItem = NSMenuItem(title: \"Compactar\", action: nil, keyEquivalent: \"e\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n installSwapHotKey()\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n removeSwapHotKey()\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n if !swapHotKeyHandlerInstalled {\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n InstallEventHandler(GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n swapHotKeyHandlerInstalled = true\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: 1) // 'AIBB'\n RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &swapHotKeyRef)\n }\n\n func removeSwapHotKey() {\n if let ref = swapHotKeyRef {\n UnregisterEventHotKey(ref)\n swapHotKeyRef = nil\n }\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// The swap-shortcut ring: every configured entry (vendors *and* Claude\n /// accounts) plus the synthetic \"overview\" target, so ⌥⌘\\ cycles them all.\n func swapCycleIds() -> [String] {\n var ids = vendorEntries(active: VENDOR).map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per possible vendor for Overview mode. ponytail: 12 ≥\n // the vendor count; bump if VENDOR_AUTH ever grows past it.\n for _ in 0..<12 {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n // First item after the usage rows, Overview-only (hidden elsewhere).\n compactItem.action = #selector(toggleCompact)\n compactItem.target = self\n compactItem.isHidden = true\n menu.addItem(compactItem)\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n /// Flip the overview's compact mode; the UserDefaults observer re-renders,\n /// which also relabels the item (Compactar ↔ Expandir).\n @objc func toggleCompact() {\n DEF.set(!DEF.bool(forKey: \"overviewCompact\"), forKey: \"overviewCompact\")\n }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = entryDisplayName(VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: vendor) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n // vendorEntries(active: \"\") keeps only configured entries — the\n // active-vendor courtesy slot has no place in an all-vendors sweep.\n let entries = vendorEntries(active: \"\")\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in entries {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: v.id) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: baseVendorId(v.id))\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// Compact bar label for a vendor name. Usually the first word; for a\n /// Claude account (\"Claude · gmail\") the account label is the part that\n /// distinguishes entries, so use it instead of the shared \"Claude\".\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let base = name.range(of: \" · \").map { String(name[$0.upperBound...]) }\n ?? String(name.split(separator: \" \").first ?? Substring(name))\n return base.count <= maxLen ? base : String(base.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 4) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String)] = items.compactMap {\n guard let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\") }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\")\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 4\n let compact = DEF.bool(forKey: \"overviewCompact\")\n let t = NSMutableAttributedString()\n if overviewUsesBars(count: heads.count, barsMax: barsMax, compact: compact) {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n // Entry order, not worst-first: entries are provider-grouped\n // (standard Claude, then its accounts, then the other vendors), and\n // stable positions beat a pct sort that reshuffles labels on every\n // refresh.\n for (i, e) in heads.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n }\n if heads.count > maxN { t.append(run(\" +\\(heads.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to jump straight to that vendor's detail view.\n it.representedObject = item.id\n it.target = self\n it.action = #selector(switchVendor(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n compactItem.isHidden = false\n compactItem.title = DEF.bool(forKey: \"overviewCompact\") ? \"Expandir\" : \"Compactar\"\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: baseVendorId(VENDOR)) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n // With several Claude accounts the plan alone (\"Claude Max 20x\") no\n // longer says WHICH account this is — suffix the label.\n let plan = s.plan.isEmpty ? \"AI Usage\" : s.plan\n let header = accountLabel(of: VENDOR).map { \"\\(plan) · \\($0)\" } ?? plan\n headerItem.attributedTitle = run(header, .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let entries = vendorEntries(active: active)\n if entries.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in entries {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "6d812976568438e8c5f68ec073eea15824e6dbcb": {"t": 1785750141.3160715, "n": 75819, "h": "8f72b15e9b0dfbb0", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker.\n private var vendors: [String] {\n VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\n\n/// Default shortcut: `\\` (kVK_ANSI_Backslash) with Command+Option. `[ui]` on\n/// Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n NotificationCenter.default.post(name: swapHotKeyNotification, object: nil)\n return noErr\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\n/// Whether the overview status-bar title draws mini bars (vs. the compact\n/// %-text mode). \"Compactar\" forces the text mode even under the bars-count\n/// threshold. Pure + testable — the render path is not.\nfunc overviewUsesBars(count: Int, barsMax: Int, compact: Bool) -> Bool {\n !compact && count <= barsMax\n}\n\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor); hidden in single-vendor mode.\n // A fixed pool avoids rebuilding the menu (which would disrupt an open menu).\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n /// Overview-only: forces the status-bar title into the compact %-text mode\n /// (\"Compactar\"); while compact it reads \"Expandir\" and turns it back off.\n let compactItem = NSMenuItem(title: \"Compactar\", action: nil, keyEquivalent: \"e\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n installSwapHotKey()\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n removeSwapHotKey()\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n if !swapHotKeyHandlerInstalled {\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n InstallEventHandler(GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n swapHotKeyHandlerInstalled = true\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: 1) // 'AIBB'\n RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &swapHotKeyRef)\n }\n\n func removeSwapHotKey() {\n if let ref = swapHotKeyRef {\n UnregisterEventHotKey(ref)\n swapHotKeyRef = nil\n }\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// The swap-shortcut ring: every configured vendor plus the synthetic\n /// \"overview\" target, so ⌥⌘\\ cycles all providers *and* the overview.\n func swapCycleIds() -> [String] {\n var ids = VENDOR_AUTH\n .filter { vendorEnabled($0) && ($0.id == VENDOR || vendorConfigured($0)) }\n .map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per possible vendor for Overview mode. ponytail: 12 ≥\n // the vendor count; bump if VENDOR_AUTH ever grows past it.\n for _ in 0..<12 {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n // First item after the usage rows, Overview-only (hidden elsewhere).\n compactItem.action = #selector(toggleCompact)\n compactItem.target = self\n compactItem.isHidden = true\n menu.addItem(compactItem)\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n /// Flip the overview's compact mode; the UserDefaults observer re-renders,\n /// which also relabels the item (Compactar ↔ Expandir).\n @objc func toggleCompact() {\n DEF.set(!DEF.bool(forKey: \"overviewCompact\"), forKey: \"overviewCompact\")\n }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = VENDOR == \"overview\"\n ? \"Visão geral\"\n : (VENDOR_AUTH.first { $0.id == VENDOR }?.name ?? VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = [\"--vendor\", vendor, \"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n let vendors = VENDOR_AUTH.filter { vendorEnabled($0) && vendorConfigured($0) }\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in vendors {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = [\"--vendor\", v.id, \"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: v.id)\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// First word of a vendor name, clipped to `maxLen` — a compact bar label.\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let first = String(name.split(separator: \" \").first ?? Substring(name))\n return first.count <= maxLen ? first : String(first.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 4) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String)] = items.compactMap {\n guard let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\") }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\")\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 4\n let compact = DEF.bool(forKey: \"overviewCompact\")\n let t = NSMutableAttributedString()\n if overviewUsesBars(count: heads.count, barsMax: barsMax, compact: compact) {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n // Entry order, not worst-first: entries are provider-grouped\n // (standard Claude, then its accounts, then the other vendors), and\n // stable positions beat a pct sort that reshuffles labels on every\n // refresh.\n for (i, e) in heads.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n }\n if heads.count > maxN { t.append(run(\" +\\(heads.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to jump straight to that vendor's detail view.\n it.representedObject = item.id\n it.target = self\n it.action = #selector(switchVendor(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n compactItem.isHidden = false\n compactItem.title = DEF.bool(forKey: \"overviewCompact\") ? \"Expandir\" : \"Compactar\"\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: VENDOR) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n headerItem.attributedTitle = run(s.plan.isEmpty ? \"AI Usage\" : s.plan,\n .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let configured = VENDOR_AUTH.filter { vendorEnabled($0) && ($0.id == active || vendorConfigured($0)) }\n if configured.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in configured {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n compactItem.isHidden = true\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "4511b5afe174dbad080874737de233f4f94dca72": {"t": 1785750143.812977, "n": 83960, "h": "b5d9c4da2cb8e8c3", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n// ── Anthropic multi-account ────────────────────────────────────────────────\n// Mirrors the Rust side (`src/config.rs`): explicit `[[anthropic.accounts]]`\n// entries plus subdirectories of `[anthropic] accounts_dir` holding a\n// `.credentials.json`, explicit labels winning on a clash. Each account is a\n// selectable entry with the pseudo-id `anthropic@<label>`, fetched by the\n// binary as `--vendor anthropic --account <label>` — the binary resolves the\n// account's credentials itself (file or its own Keychain item), so the app\n// never needs to know where they live.\n\n/// `[[anthropic.accounts]]` labels, in file order. Pure text scan, like the\n/// rest of this app's TOML reading: a `[[anthropic.accounts]]` header opens a\n/// block, the first `label = \"…\"` inside it counts.\nfunc anthropicAccountLabels(inTOML text: String) -> [String] {\n var labels: [String] = []\n var inBlock = false\n for rawLine in text.components(separatedBy: .newlines) {\n let line = rawLine.trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inBlock = line == \"[[anthropic.accounts]]\"\n continue\n }\n guard inBlock, line.hasPrefix(\"label\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == \"label\" else { continue }\n var value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n value = String(content[..<end])\n }\n if !value.isEmpty {\n labels.append(value)\n inBlock = false // one label per block\n }\n }\n return labels\n}\n\n/// Subdirectories of `dir` holding a `.credentials.json`, sorted — the same\n/// discovery rule as the Rust `accounts_dir` scan.\nfunc discoverAccountLabels(inDir dir: String) -> [String] {\n let fm = FileManager.default\n guard let names = try? fm.contentsOfDirectory(atPath: dir) else { return [] }\n return names.filter { name in\n var isDir: ObjCBool = false\n let sub = \"\\(dir)/\\(name)\"\n return fm.fileExists(atPath: sub, isDirectory: &isDir) && isDir.boolValue\n && fm.fileExists(atPath: \"\\(sub)/.credentials.json\")\n }.sorted()\n}\n\n/// Explicit labels first (file order), then discovered ones that don't clash.\nfunc mergedAccountLabels(explicit: [String], discovered: [String]) -> [String] {\n explicit + discovered.filter { !explicit.contains($0) }\n}\n\n/// All configured Claude account labels, from the same config the binary reads.\nfunc claudeAccountLabels() -> [String] {\n guard let text = try? String(contentsOfFile: configPathTOML(), encoding: .utf8) else {\n return []\n }\n let explicit = anthropicAccountLabels(inTOML: text)\n var discovered: [String] = []\n if let dir = tomlValueInText(text, section: \"anthropic\", key: \"accounts_dir\"), !dir.isEmpty {\n discovered = discoverAccountLabels(inDir: NSString(string: dir).expandingTildeInPath)\n }\n return mergedAccountLabels(explicit: explicit, discovered: discovered)\n}\n\n/// Rust semantics (`show_default_account`): `false` hides the default\n/// (unnamed) Claude entry, but is ignored when there are no named accounts,\n/// so Anthropic never loses its only entry.\nfunc showDefaultClaudeAccount(configValue: String?, hasAccounts: Bool) -> Bool {\n guard hasAccounts else { return true }\n return configValue != \"false\"\n}\n\n/// Pseudo-id mapping: `anthropic@<label>` selects a named account.\nlet ACCOUNT_ID_PREFIX = \"anthropic@\"\n\nfunc accountLabel(of id: String) -> String? {\n id.hasPrefix(ACCOUNT_ID_PREFIX) ? String(id.dropFirst(ACCOUNT_ID_PREFIX.count)) : nil\n}\n\nfunc baseVendorId(_ id: String) -> String {\n accountLabel(of: id) != nil ? \"anthropic\" : id\n}\n\n/// The subprocess arguments that select `id` (base vendor or named account).\nfunc vendorArgs(for id: String) -> [String] {\n if let label = accountLabel(of: id) {\n return [\"--vendor\", \"anthropic\", \"--account\", label]\n }\n return [\"--vendor\", id]\n}\n\n/// One selectable entry: a base vendor or one Claude account.\nstruct MenuEntry {\n let id: String // \"cursor\", \"anthropic\", or \"anthropic@<label>\"\n let name: String // display: \"Cursor\", \"Claude · <label>\"\n}\n\n/// The selectable entries, in menu order: every enabled+configured vendor,\n/// with Anthropic expanded into its named accounts (the default entry kept\n/// only per `show_default_account`). `active` stays listed even when\n/// unconfigured — same rule the per-vendor list always had.\nfunc vendorEntries(active: String) -> [MenuEntry] {\n var out: [MenuEntry] = []\n for v in VENDOR_AUTH where vendorEnabled(v) {\n if v.id == \"anthropic\" {\n let labels = claudeAccountLabels()\n let showDefault = showDefaultClaudeAccount(\n configValue: configValueTOML(\"anthropic\", \"show_default_account\"),\n hasAccounts: !labels.isEmpty)\n if showDefault && (v.id == active || vendorConfigured(v)) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n for label in labels {\n out.append(MenuEntry(id: ACCOUNT_ID_PREFIX + label, name: \"Claude · \\(label)\"))\n }\n } else if v.id == active || vendorConfigured(v) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n }\n return out\n}\n\n/// Display name for any selectable id (base vendor, account, or overview).\nfunc entryDisplayName(_ id: String) -> String {\n if id == \"overview\" { return \"Visão geral\" }\n if let label = accountLabel(of: id) { return \"Claude · \\(label)\" }\n return VENDOR_AUTH.first { $0.id == id }?.name ?? id\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n @AppStorage(\"launchAtLogin\") private var launchAtLogin = false\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker. Claude\n // accounts appear as their `anthropic@<label>` pseudo-ids, same as the\n // \"Trocar vendor\" submenu.\n private var vendors: [String] {\n var ids = VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n let labels = claudeAccountLabels()\n if let at = ids.firstIndex(of: \"anthropic\") {\n ids.insert(contentsOf: labels.map { ACCOUNT_ID_PREFIX + $0 }, at: at + 1)\n }\n return ids\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Sistema\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Iniciar no login\", isOn: $launchAtLogin)\n .onChange(of: launchAtLogin) { value in setLaunchAtLogin(value) }\n Text(\"Instala um LaunchAgent que sobe o app ao entrar na sua conta.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n }\n}\n\n// ─── Launch at login (macOS LaunchAgent) ─────────────────────────────────\n//\n// The app is an unbundled single binary, so the modern SMAppService.mainApp\n// API (which needs a bundle id) doesn't apply. A per-user LaunchAgent is the\n// portable way to start an unbundled executable at login — the same mechanism\n// install-agent.sh sets up, now toggleable from Preferences.\nlet LAUNCH_AGENT_LABEL = \"com.akitaonrails.ai-usagebar-menubar\"\n\nfunc launchAgentPlistPath() -> String {\n \"\\(NSHomeDirectory())/Library/LaunchAgents/\\(LAUNCH_AGENT_LABEL).plist\"\n}\n\n/// Absolute path of the running executable, for the LaunchAgent to relaunch.\nfunc selfExecutablePath() -> String {\n if let p = Bundle.main.executablePath, !p.isEmpty { return p }\n let arg0 = CommandLine.arguments.first ?? \"ai-usagebar-menubar\"\n if arg0.hasPrefix(\"/\") { return arg0 }\n return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)\n .appendingPathComponent(arg0).standardized.path\n}\n\n/// Install (or remove) the login LaunchAgent by managing its plist file.\n/// Building the plist via PropertyListSerialization sidesteps the XML-escaping\n/// the shell installer has to do by hand.\n///\n/// Deliberately no `launchctl load`/`unload`: the app is already running when\n/// this is toggled, so `RunAtLoad` on load would spawn a *second* copy, and\n/// `unload` on disable would SIGTERM this very process (quitting the app the\n/// user is still using) if it happened to be the launchd-managed one. launchd\n/// loads every agent in ~/Library/LaunchAgents at the next login, so writing\n/// (or removing) the file is all that's needed for a start-at-login toggle.\nfunc setLaunchAtLogin(_ enabled: Bool) {\n let path = launchAgentPlistPath()\n if enabled {\n let dir = (path as NSString).deletingLastPathComponent\n try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)\n let plist: [String: Any] = [\n \"Label\": LAUNCH_AGENT_LABEL,\n \"ProgramArguments\": [selfExecutablePath()],\n \"RunAtLoad\": true,\n \"ProcessType\": \"Interactive\",\n ]\n if let data = try? PropertyListSerialization.data(\n fromPropertyList: plist, format: .xml, options: 0) {\n try? data.write(to: URL(fileURLWithPath: path))\n }\n } else {\n try? FileManager.default.removeItem(atPath: path)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\n\n/// Default shortcut: `\\` (kVK_ANSI_Backslash) with Command+Option. `[ui]` on\n/// Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n NotificationCenter.default.post(name: swapHotKeyNotification, object: nil)\n return noErr\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor); hidden in single-vendor mode.\n // A fixed pool avoids rebuilding the menu (which would disrupt an open menu).\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n installSwapHotKey()\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n removeSwapHotKey()\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n if !swapHotKeyHandlerInstalled {\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n InstallEventHandler(GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n swapHotKeyHandlerInstalled = true\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: 1) // 'AIBB'\n RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &swapHotKeyRef)\n }\n\n func removeSwapHotKey() {\n if let ref = swapHotKeyRef {\n UnregisterEventHotKey(ref)\n swapHotKeyRef = nil\n }\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// The swap-shortcut ring: every configured entry (vendors *and* Claude\n /// accounts) plus the synthetic \"overview\" target, so ⌥⌘\\ cycles them all.\n func swapCycleIds() -> [String] {\n var ids = vendorEntries(active: VENDOR).map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per possible vendor for Overview mode. ponytail: 12 ≥\n // the vendor count; bump if VENDOR_AUTH ever grows past it.\n for _ in 0..<12 {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = entryDisplayName(VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: vendor) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n // vendorEntries(active: \"\") keeps only configured entries — the\n // active-vendor courtesy slot has no place in an all-vendors sweep.\n let entries = vendorEntries(active: \"\")\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in entries {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: v.id) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: baseVendorId(v.id))\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// Compact bar label for a vendor name. Usually the first word; for a\n /// Claude account (\"Claude · gmail\") the account label is the part that\n /// distinguishes entries, so use it instead of the shared \"Claude\".\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let base = name.range(of: \" · \").map { String(name[$0.upperBound...]) }\n ?? String(name.split(separator: \" \").first ?? Substring(name))\n return base.count <= maxLen ? base : String(base.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 4) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String)] = items.compactMap {\n guard let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\") }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\")\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 4\n let t = NSMutableAttributedString()\n if heads.count <= barsMax {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n // Entry order, not worst-first: entries are provider-grouped\n // (standard Claude, then its accounts, then the other vendors), and\n // stable positions beat a pct sort that reshuffles labels on every\n // refresh.\n for (i, e) in heads.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n }\n if heads.count > maxN { t.append(run(\" +\\(heads.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to jump straight to that vendor's detail view.\n it.representedObject = item.id\n it.target = self\n it.action = #selector(switchVendor(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: baseVendorId(VENDOR)) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n // With several Claude accounts the plan alone (\"Claude Max 20x\") no\n // longer says WHICH account this is — suffix the label.\n let plan = s.plan.isEmpty ? \"AI Usage\" : s.plan\n let header = accountLabel(of: VENDOR).map { \"\\(plan) · \\($0)\" } ?? plan\n headerItem.attributedTitle = run(header, .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let entries = vendorEntries(active: active)\n if entries.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in entries {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "75e9dff992c628be239592ce8e2daa8502390e1e": {"t": 1785750145.2431905, "n": 80948, "h": "df12fae1cbc5d222", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n// ── Anthropic multi-account ────────────────────────────────────────────────\n// Mirrors the Rust side (`src/config.rs`): explicit `[[anthropic.accounts]]`\n// entries plus subdirectories of `[anthropic] accounts_dir` holding a\n// `.credentials.json`, explicit labels winning on a clash. Each account is a\n// selectable entry with the pseudo-id `anthropic@<label>`, fetched by the\n// binary as `--vendor anthropic --account <label>` — the binary resolves the\n// account's credentials itself (file or its own Keychain item), so the app\n// never needs to know where they live.\n\n/// `[[anthropic.accounts]]` labels, in file order. Pure text scan, like the\n/// rest of this app's TOML reading: a `[[anthropic.accounts]]` header opens a\n/// block, the first `label = \"…\"` inside it counts.\nfunc anthropicAccountLabels(inTOML text: String) -> [String] {\n var labels: [String] = []\n var inBlock = false\n for rawLine in text.components(separatedBy: .newlines) {\n let line = rawLine.trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inBlock = line == \"[[anthropic.accounts]]\"\n continue\n }\n guard inBlock, line.hasPrefix(\"label\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == \"label\" else { continue }\n var value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n value = String(content[..<end])\n }\n if !value.isEmpty {\n labels.append(value)\n inBlock = false // one label per block\n }\n }\n return labels\n}\n\n/// Subdirectories of `dir` holding a `.credentials.json`, sorted — the same\n/// discovery rule as the Rust `accounts_dir` scan.\nfunc discoverAccountLabels(inDir dir: String) -> [String] {\n let fm = FileManager.default\n guard let names = try? fm.contentsOfDirectory(atPath: dir) else { return [] }\n return names.filter { name in\n var isDir: ObjCBool = false\n let sub = \"\\(dir)/\\(name)\"\n return fm.fileExists(atPath: sub, isDirectory: &isDir) && isDir.boolValue\n && fm.fileExists(atPath: \"\\(sub)/.credentials.json\")\n }.sorted()\n}\n\n/// Explicit labels first (file order), then discovered ones that don't clash.\nfunc mergedAccountLabels(explicit: [String], discovered: [String]) -> [String] {\n explicit + discovered.filter { !explicit.contains($0) }\n}\n\n/// All configured Claude account labels, from the same config the binary reads.\nfunc claudeAccountLabels() -> [String] {\n guard let text = try? String(contentsOfFile: configPathTOML(), encoding: .utf8) else {\n return []\n }\n let explicit = anthropicAccountLabels(inTOML: text)\n var discovered: [String] = []\n if let dir = tomlValueInText(text, section: \"anthropic\", key: \"accounts_dir\"), !dir.isEmpty {\n discovered = discoverAccountLabels(inDir: NSString(string: dir).expandingTildeInPath)\n }\n return mergedAccountLabels(explicit: explicit, discovered: discovered)\n}\n\n/// Rust semantics (`show_default_account`): `false` hides the default\n/// (unnamed) Claude entry, but is ignored when there are no named accounts,\n/// so Anthropic never loses its only entry.\nfunc showDefaultClaudeAccount(configValue: String?, hasAccounts: Bool) -> Bool {\n guard hasAccounts else { return true }\n return configValue != \"false\"\n}\n\n/// Pseudo-id mapping: `anthropic@<label>` selects a named account.\nlet ACCOUNT_ID_PREFIX = \"anthropic@\"\n\nfunc accountLabel(of id: String) -> String? {\n id.hasPrefix(ACCOUNT_ID_PREFIX) ? String(id.dropFirst(ACCOUNT_ID_PREFIX.count)) : nil\n}\n\nfunc baseVendorId(_ id: String) -> String {\n accountLabel(of: id) != nil ? \"anthropic\" : id\n}\n\n/// The subprocess arguments that select `id` (base vendor or named account).\nfunc vendorArgs(for id: String) -> [String] {\n if let label = accountLabel(of: id) {\n return [\"--vendor\", \"anthropic\", \"--account\", label]\n }\n return [\"--vendor\", id]\n}\n\n/// One selectable entry: a base vendor or one Claude account.\nstruct MenuEntry {\n let id: String // \"cursor\", \"anthropic\", or \"anthropic@<label>\"\n let name: String // display: \"Cursor\", \"Claude · <label>\"\n}\n\n/// The selectable entries, in menu order: every enabled+configured vendor,\n/// with Anthropic expanded into its named accounts (the default entry kept\n/// only per `show_default_account`). `active` stays listed even when\n/// unconfigured — same rule the per-vendor list always had.\nfunc vendorEntries(active: String) -> [MenuEntry] {\n var out: [MenuEntry] = []\n for v in VENDOR_AUTH where vendorEnabled(v) {\n if v.id == \"anthropic\" {\n let labels = claudeAccountLabels()\n let showDefault = showDefaultClaudeAccount(\n configValue: configValueTOML(\"anthropic\", \"show_default_account\"),\n hasAccounts: !labels.isEmpty)\n if showDefault && (v.id == active || vendorConfigured(v)) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n for label in labels {\n out.append(MenuEntry(id: ACCOUNT_ID_PREFIX + label, name: \"Claude · \\(label)\"))\n }\n } else if v.id == active || vendorConfigured(v) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n }\n return out\n}\n\n/// Display name for any selectable id (base vendor, account, or overview).\nfunc entryDisplayName(_ id: String) -> String {\n if id == \"overview\" { return \"Visão geral\" }\n if let label = accountLabel(of: id) { return \"Claude · \\(label)\" }\n return VENDOR_AUTH.first { $0.id == id }?.name ?? id\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker. Claude\n // accounts appear as their `anthropic@<label>` pseudo-ids, same as the\n // \"Trocar vendor\" submenu.\n private var vendors: [String] {\n var ids = VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n let labels = claudeAccountLabels()\n if let at = ids.firstIndex(of: \"anthropic\") {\n ids.insert(contentsOf: labels.map { ACCOUNT_ID_PREFIX + $0 }, at: at + 1)\n }\n return ids\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\n\n/// Default shortcut: `\\` (kVK_ANSI_Backslash) with Command+Option. `[ui]` on\n/// Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n NotificationCenter.default.post(name: swapHotKeyNotification, object: nil)\n return noErr\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor); hidden in single-vendor mode.\n // A fixed pool avoids rebuilding the menu (which would disrupt an open menu).\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n installSwapHotKey()\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n removeSwapHotKey()\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n if !swapHotKeyHandlerInstalled {\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n InstallEventHandler(GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n swapHotKeyHandlerInstalled = true\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: 1) // 'AIBB'\n RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &swapHotKeyRef)\n }\n\n func removeSwapHotKey() {\n if let ref = swapHotKeyRef {\n UnregisterEventHotKey(ref)\n swapHotKeyRef = nil\n }\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// The swap-shortcut ring: every configured entry (vendors *and* Claude\n /// accounts) plus the synthetic \"overview\" target, so ⌥⌘\\ cycles them all.\n func swapCycleIds() -> [String] {\n var ids = vendorEntries(active: VENDOR).map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per possible vendor for Overview mode. ponytail: 12 ≥\n // the vendor count; bump if VENDOR_AUTH ever grows past it.\n for _ in 0..<12 {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = entryDisplayName(VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: vendor) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n // vendorEntries(active: \"\") keeps only configured entries — the\n // active-vendor courtesy slot has no place in an all-vendors sweep.\n let entries = vendorEntries(active: \"\")\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in entries {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: v.id) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: baseVendorId(v.id))\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// Compact bar label for a vendor name. Usually the first word; for a\n /// Claude account (\"Claude · gmail\") the account label is the part that\n /// distinguishes entries, so use it instead of the shared \"Claude\".\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let base = name.range(of: \" · \").map { String(name[$0.upperBound...]) }\n ?? String(name.split(separator: \" \").first ?? Substring(name))\n return base.count <= maxLen ? base : String(base.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 4) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String)] = items.compactMap {\n guard let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\") }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\")\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 4\n let t = NSMutableAttributedString()\n if heads.count <= barsMax {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n // Entry order, not worst-first: entries are provider-grouped\n // (standard Claude, then its accounts, then the other vendors), and\n // stable positions beat a pct sort that reshuffles labels on every\n // refresh.\n for (i, e) in heads.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n }\n if heads.count > maxN { t.append(run(\" +\\(heads.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to jump straight to that vendor's detail view.\n it.representedObject = item.id\n it.target = self\n it.action = #selector(switchVendor(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: baseVendorId(VENDOR)) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n // With several Claude accounts the plan alone (\"Claude Max 20x\") no\n // longer says WHICH account this is — suffix the label.\n let plan = s.plan.isEmpty ? \"AI Usage\" : s.plan\n let header = accountLabel(of: VENDOR).map { \"\\(plan) · \\($0)\" } ?? plan\n headerItem.attributedTitle = run(header, .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let entries = vendorEntries(active: active)\n if entries.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in entries {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "b7e8a808afe387d120296936d1b9c294e9d3f784": {"t": 1785750146.76975, "n": 74413, "h": "6cb534ccf052c995", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker.\n private var vendors: [String] {\n VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\n\n/// Default shortcut: `\\` (kVK_ANSI_Backslash) with Command+Option. `[ui]` on\n/// Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n NotificationCenter.default.post(name: swapHotKeyNotification, object: nil)\n return noErr\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor); hidden in single-vendor mode.\n // A fixed pool avoids rebuilding the menu (which would disrupt an open menu).\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n installSwapHotKey()\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n removeSwapHotKey()\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n if !swapHotKeyHandlerInstalled {\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n InstallEventHandler(GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n swapHotKeyHandlerInstalled = true\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: 1) // 'AIBB'\n RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &swapHotKeyRef)\n }\n\n func removeSwapHotKey() {\n if let ref = swapHotKeyRef {\n UnregisterEventHotKey(ref)\n swapHotKeyRef = nil\n }\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// The swap-shortcut ring: every configured vendor plus the synthetic\n /// \"overview\" target, so ⌥⌘\\ cycles all providers *and* the overview.\n func swapCycleIds() -> [String] {\n var ids = VENDOR_AUTH\n .filter { vendorEnabled($0) && ($0.id == VENDOR || vendorConfigured($0)) }\n .map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per possible vendor for Overview mode. ponytail: 12 ≥\n // the vendor count; bump if VENDOR_AUTH ever grows past it.\n for _ in 0..<12 {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = VENDOR == \"overview\"\n ? \"Visão geral\"\n : (VENDOR_AUTH.first { $0.id == VENDOR }?.name ?? VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = [\"--vendor\", vendor, \"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n let vendors = VENDOR_AUTH.filter { vendorEnabled($0) && vendorConfigured($0) }\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in vendors {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = [\"--vendor\", v.id, \"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: v.id)\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// First word of a vendor name, clipped to `maxLen` — a compact bar label.\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let first = String(name.split(separator: \" \").first ?? Substring(name))\n return first.count <= maxLen ? first : String(first.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 4) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String)] = items.compactMap {\n guard let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\") }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\")\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 4\n let t = NSMutableAttributedString()\n if heads.count <= barsMax {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n // Entry order, not worst-first: entries are provider-grouped\n // (standard Claude, then its accounts, then the other vendors), and\n // stable positions beat a pct sort that reshuffles labels on every\n // refresh.\n for (i, e) in heads.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n }\n if heads.count > maxN { t.append(run(\" +\\(heads.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to jump straight to that vendor's detail view.\n it.representedObject = item.id\n it.target = self\n it.action = #selector(switchVendor(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: VENDOR) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n headerItem.attributedTitle = run(s.plan.isEmpty ? \"AI Usage\" : s.plan,\n .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let configured = VENDOR_AUTH.filter { vendorEnabled($0) && ($0.id == active || vendorConfigured($0)) }\n if configured.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in configured {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "77bb43a06d2316969c695733f01b6a67e5f48cbf": {"t": 1785750148.374805, "n": 83762, "h": "d411274be87f6655", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n// ── Anthropic multi-account ────────────────────────────────────────────────\n// Mirrors the Rust side (`src/config.rs`): explicit `[[anthropic.accounts]]`\n// entries plus subdirectories of `[anthropic] accounts_dir` holding a\n// `.credentials.json`, explicit labels winning on a clash. Each account is a\n// selectable entry with the pseudo-id `anthropic@<label>`, fetched by the\n// binary as `--vendor anthropic --account <label>` — the binary resolves the\n// account's credentials itself (file or its own Keychain item), so the app\n// never needs to know where they live.\n\n/// `[[anthropic.accounts]]` labels, in file order. Pure text scan, like the\n/// rest of this app's TOML reading: a `[[anthropic.accounts]]` header opens a\n/// block, the first `label = \"…\"` inside it counts.\nfunc anthropicAccountLabels(inTOML text: String) -> [String] {\n var labels: [String] = []\n var inBlock = false\n for rawLine in text.components(separatedBy: .newlines) {\n let line = rawLine.trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inBlock = line == \"[[anthropic.accounts]]\"\n continue\n }\n guard inBlock, line.hasPrefix(\"label\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == \"label\" else { continue }\n var value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n value = String(content[..<end])\n }\n if !value.isEmpty {\n labels.append(value)\n inBlock = false // one label per block\n }\n }\n return labels\n}\n\n/// Subdirectories of `dir` holding a `.credentials.json`, sorted — the same\n/// discovery rule as the Rust `accounts_dir` scan.\nfunc discoverAccountLabels(inDir dir: String) -> [String] {\n let fm = FileManager.default\n guard let names = try? fm.contentsOfDirectory(atPath: dir) else { return [] }\n return names.filter { name in\n var isDir: ObjCBool = false\n let sub = \"\\(dir)/\\(name)\"\n return fm.fileExists(atPath: sub, isDirectory: &isDir) && isDir.boolValue\n && fm.fileExists(atPath: \"\\(sub)/.credentials.json\")\n }.sorted()\n}\n\n/// Explicit labels first (file order), then discovered ones that don't clash.\nfunc mergedAccountLabels(explicit: [String], discovered: [String]) -> [String] {\n explicit + discovered.filter { !explicit.contains($0) }\n}\n\n/// All configured Claude account labels, from the same config the binary reads.\nfunc claudeAccountLabels() -> [String] {\n guard let text = try? String(contentsOfFile: configPathTOML(), encoding: .utf8) else {\n return []\n }\n let explicit = anthropicAccountLabels(inTOML: text)\n var discovered: [String] = []\n if let dir = tomlValueInText(text, section: \"anthropic\", key: \"accounts_dir\"), !dir.isEmpty {\n discovered = discoverAccountLabels(inDir: NSString(string: dir).expandingTildeInPath)\n }\n return mergedAccountLabels(explicit: explicit, discovered: discovered)\n}\n\n/// Rust semantics (`show_default_account`): `false` hides the default\n/// (unnamed) Claude entry, but is ignored when there are no named accounts,\n/// so Anthropic never loses its only entry.\nfunc showDefaultClaudeAccount(configValue: String?, hasAccounts: Bool) -> Bool {\n guard hasAccounts else { return true }\n return configValue != \"false\"\n}\n\n/// Pseudo-id mapping: `anthropic@<label>` selects a named account.\nlet ACCOUNT_ID_PREFIX = \"anthropic@\"\n\nfunc accountLabel(of id: String) -> String? {\n id.hasPrefix(ACCOUNT_ID_PREFIX) ? String(id.dropFirst(ACCOUNT_ID_PREFIX.count)) : nil\n}\n\nfunc baseVendorId(_ id: String) -> String {\n accountLabel(of: id) != nil ? \"anthropic\" : id\n}\n\n/// The subprocess arguments that select `id` (base vendor or named account).\nfunc vendorArgs(for id: String) -> [String] {\n if let label = accountLabel(of: id) {\n return [\"--vendor\", \"anthropic\", \"--account\", label]\n }\n return [\"--vendor\", id]\n}\n\n/// One selectable entry: a base vendor or one Claude account.\nstruct MenuEntry {\n let id: String // \"cursor\", \"anthropic\", or \"anthropic@<label>\"\n let name: String // display: \"Cursor\", \"Claude · <label>\"\n}\n\n/// The selectable entries, in menu order: every enabled+configured vendor,\n/// with Anthropic expanded into its named accounts (the default entry kept\n/// only per `show_default_account`). `active` stays listed even when\n/// unconfigured — same rule the per-vendor list always had.\nfunc vendorEntries(active: String) -> [MenuEntry] {\n var out: [MenuEntry] = []\n for v in VENDOR_AUTH where vendorEnabled(v) {\n if v.id == \"anthropic\" {\n let labels = claudeAccountLabels()\n let showDefault = showDefaultClaudeAccount(\n configValue: configValueTOML(\"anthropic\", \"show_default_account\"),\n hasAccounts: !labels.isEmpty)\n if showDefault && (v.id == active || vendorConfigured(v)) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n for label in labels {\n out.append(MenuEntry(id: ACCOUNT_ID_PREFIX + label, name: \"Claude · \\(label)\"))\n }\n } else if v.id == active || vendorConfigured(v) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n }\n return out\n}\n\n/// Display name for any selectable id (base vendor, account, or overview).\nfunc entryDisplayName(_ id: String) -> String {\n if id == \"overview\" { return \"Visão geral\" }\n if let label = accountLabel(of: id) { return \"Claude · \\(label)\" }\n return VENDOR_AUTH.first { $0.id == id }?.name ?? id\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n @AppStorage(\"launchAtLogin\") private var launchAtLogin = false\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker. Claude\n // accounts appear as their `anthropic@<label>` pseudo-ids, same as the\n // \"Trocar vendor\" submenu.\n private var vendors: [String] {\n var ids = VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n let labels = claudeAccountLabels()\n if let at = ids.firstIndex(of: \"anthropic\") {\n ids.insert(contentsOf: labels.map { ACCOUNT_ID_PREFIX + $0 }, at: at + 1)\n }\n return ids\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Sistema\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Iniciar no login\", isOn: $launchAtLogin)\n .onChange(of: launchAtLogin) { value in setLaunchAtLogin(value) }\n Text(\"Instala um LaunchAgent que sobe o app ao entrar na sua conta.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n }\n}\n\n// ─── Launch at login (macOS LaunchAgent) ─────────────────────────────────\n//\n// The app is an unbundled single binary, so the modern SMAppService.mainApp\n// API (which needs a bundle id) doesn't apply. A per-user LaunchAgent is the\n// portable way to start an unbundled executable at login — the same mechanism\n// install-agent.sh sets up, now toggleable from Preferences.\nlet LAUNCH_AGENT_LABEL = \"com.akitaonrails.ai-usagebar-menubar\"\n\nfunc launchAgentPlistPath() -> String {\n \"\\(NSHomeDirectory())/Library/LaunchAgents/\\(LAUNCH_AGENT_LABEL).plist\"\n}\n\n/// Absolute path of the running executable, for the LaunchAgent to relaunch.\nfunc selfExecutablePath() -> String {\n if let p = Bundle.main.executablePath, !p.isEmpty { return p }\n let arg0 = CommandLine.arguments.first ?? \"ai-usagebar-menubar\"\n if arg0.hasPrefix(\"/\") { return arg0 }\n return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)\n .appendingPathComponent(arg0).standardized.path\n}\n\n/// Install (or remove) the login LaunchAgent by managing its plist file.\n/// Building the plist via PropertyListSerialization sidesteps the XML-escaping\n/// the shell installer has to do by hand.\n///\n/// Deliberately no `launchctl load`/`unload`: the app is already running when\n/// this is toggled, so `RunAtLoad` on load would spawn a *second* copy, and\n/// `unload` on disable would SIGTERM this very process (quitting the app the\n/// user is still using) if it happened to be the launchd-managed one. launchd\n/// loads every agent in ~/Library/LaunchAgents at the next login, so writing\n/// (or removing) the file is all that's needed for a start-at-login toggle.\nfunc setLaunchAtLogin(_ enabled: Bool) {\n let path = launchAgentPlistPath()\n if enabled {\n let dir = (path as NSString).deletingLastPathComponent\n try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)\n let plist: [String: Any] = [\n \"Label\": LAUNCH_AGENT_LABEL,\n \"ProgramArguments\": [selfExecutablePath()],\n \"RunAtLoad\": true,\n \"ProcessType\": \"Interactive\",\n ]\n if let data = try? PropertyListSerialization.data(\n fromPropertyList: plist, format: .xml, options: 0) {\n try? data.write(to: URL(fileURLWithPath: path))\n }\n } else {\n try? FileManager.default.removeItem(atPath: path)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\n\n/// Default shortcut: `\\` (kVK_ANSI_Backslash) with Command+Option. `[ui]` on\n/// Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n NotificationCenter.default.post(name: swapHotKeyNotification, object: nil)\n return noErr\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor); hidden in single-vendor mode.\n // A fixed pool avoids rebuilding the menu (which would disrupt an open menu).\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n installSwapHotKey()\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n removeSwapHotKey()\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n if !swapHotKeyHandlerInstalled {\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n InstallEventHandler(GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n swapHotKeyHandlerInstalled = true\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: 1) // 'AIBB'\n RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &swapHotKeyRef)\n }\n\n func removeSwapHotKey() {\n if let ref = swapHotKeyRef {\n UnregisterEventHotKey(ref)\n swapHotKeyRef = nil\n }\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// The swap-shortcut ring: every configured entry (vendors *and* Claude\n /// accounts) plus the synthetic \"overview\" target, so ⌥⌘\\ cycles them all.\n func swapCycleIds() -> [String] {\n var ids = vendorEntries(active: VENDOR).map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per possible vendor for Overview mode. ponytail: 12 ≥\n // the vendor count; bump if VENDOR_AUTH ever grows past it.\n for _ in 0..<12 {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = entryDisplayName(VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: vendor) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n // vendorEntries(active: \"\") keeps only configured entries — the\n // active-vendor courtesy slot has no place in an all-vendors sweep.\n let entries = vendorEntries(active: \"\")\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in entries {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: v.id) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: baseVendorId(v.id))\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// Compact bar label for a vendor name. Usually the first word; for a\n /// Claude account (\"Claude · gmail\") the account label is the part that\n /// distinguishes entries, so use it instead of the shared \"Claude\".\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let base = name.range(of: \" · \").map { String(name[$0.upperBound...]) }\n ?? String(name.split(separator: \" \").first ?? Substring(name))\n return base.count <= maxLen ? base : String(base.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 2) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String)] = items.compactMap {\n guard let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\") }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\")\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 2\n let t = NSMutableAttributedString()\n if heads.count <= barsMax {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n let sorted = heads.sorted { $0.pct > $1.pct }\n for (i, e) in sorted.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n }\n if sorted.count > maxN { t.append(run(\" +\\(sorted.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to jump straight to that vendor's detail view.\n it.representedObject = item.id\n it.target = self\n it.action = #selector(switchVendor(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: baseVendorId(VENDOR)) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n // With several Claude accounts the plan alone (\"Claude Max 20x\") no\n // longer says WHICH account this is — suffix the label.\n let plan = s.plan.isEmpty ? \"AI Usage\" : s.plan\n let header = accountLabel(of: VENDOR).map { \"\\(plan) · \\($0)\" } ?? plan\n headerItem.attributedTitle = run(header, .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let entries = vendorEntries(active: active)\n if entries.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in entries {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "a5fe306022df9aab1e9741b32d67f9a66a004c15": {"t": 1785750150.23586, "n": 80750, "h": "8803eea7732aa5fc", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n// ── Anthropic multi-account ────────────────────────────────────────────────\n// Mirrors the Rust side (`src/config.rs`): explicit `[[anthropic.accounts]]`\n// entries plus subdirectories of `[anthropic] accounts_dir` holding a\n// `.credentials.json`, explicit labels winning on a clash. Each account is a\n// selectable entry with the pseudo-id `anthropic@<label>`, fetched by the\n// binary as `--vendor anthropic --account <label>` — the binary resolves the\n// account's credentials itself (file or its own Keychain item), so the app\n// never needs to know where they live.\n\n/// `[[anthropic.accounts]]` labels, in file order. Pure text scan, like the\n/// rest of this app's TOML reading: a `[[anthropic.accounts]]` header opens a\n/// block, the first `label = \"…\"` inside it counts.\nfunc anthropicAccountLabels(inTOML text: String) -> [String] {\n var labels: [String] = []\n var inBlock = false\n for rawLine in text.components(separatedBy: .newlines) {\n let line = rawLine.trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inBlock = line == \"[[anthropic.accounts]]\"\n continue\n }\n guard inBlock, line.hasPrefix(\"label\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1)\n guard parts.count == 2,\n parts[0].trimmingCharacters(in: .whitespaces) == \"label\" else { continue }\n var value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n value = String(content[..<end])\n }\n if !value.isEmpty {\n labels.append(value)\n inBlock = false // one label per block\n }\n }\n return labels\n}\n\n/// Subdirectories of `dir` holding a `.credentials.json`, sorted — the same\n/// discovery rule as the Rust `accounts_dir` scan.\nfunc discoverAccountLabels(inDir dir: String) -> [String] {\n let fm = FileManager.default\n guard let names = try? fm.contentsOfDirectory(atPath: dir) else { return [] }\n return names.filter { name in\n var isDir: ObjCBool = false\n let sub = \"\\(dir)/\\(name)\"\n return fm.fileExists(atPath: sub, isDirectory: &isDir) && isDir.boolValue\n && fm.fileExists(atPath: \"\\(sub)/.credentials.json\")\n }.sorted()\n}\n\n/// Explicit labels first (file order), then discovered ones that don't clash.\nfunc mergedAccountLabels(explicit: [String], discovered: [String]) -> [String] {\n explicit + discovered.filter { !explicit.contains($0) }\n}\n\n/// All configured Claude account labels, from the same config the binary reads.\nfunc claudeAccountLabels() -> [String] {\n guard let text = try? String(contentsOfFile: configPathTOML(), encoding: .utf8) else {\n return []\n }\n let explicit = anthropicAccountLabels(inTOML: text)\n var discovered: [String] = []\n if let dir = tomlValueInText(text, section: \"anthropic\", key: \"accounts_dir\"), !dir.isEmpty {\n discovered = discoverAccountLabels(inDir: NSString(string: dir).expandingTildeInPath)\n }\n return mergedAccountLabels(explicit: explicit, discovered: discovered)\n}\n\n/// Rust semantics (`show_default_account`): `false` hides the default\n/// (unnamed) Claude entry, but is ignored when there are no named accounts,\n/// so Anthropic never loses its only entry.\nfunc showDefaultClaudeAccount(configValue: String?, hasAccounts: Bool) -> Bool {\n guard hasAccounts else { return true }\n return configValue != \"false\"\n}\n\n/// Pseudo-id mapping: `anthropic@<label>` selects a named account.\nlet ACCOUNT_ID_PREFIX = \"anthropic@\"\n\nfunc accountLabel(of id: String) -> String? {\n id.hasPrefix(ACCOUNT_ID_PREFIX) ? String(id.dropFirst(ACCOUNT_ID_PREFIX.count)) : nil\n}\n\nfunc baseVendorId(_ id: String) -> String {\n accountLabel(of: id) != nil ? \"anthropic\" : id\n}\n\n/// The subprocess arguments that select `id` (base vendor or named account).\nfunc vendorArgs(for id: String) -> [String] {\n if let label = accountLabel(of: id) {\n return [\"--vendor\", \"anthropic\", \"--account\", label]\n }\n return [\"--vendor\", id]\n}\n\n/// One selectable entry: a base vendor or one Claude account.\nstruct MenuEntry {\n let id: String // \"cursor\", \"anthropic\", or \"anthropic@<label>\"\n let name: String // display: \"Cursor\", \"Claude · <label>\"\n}\n\n/// The selectable entries, in menu order: every enabled+configured vendor,\n/// with Anthropic expanded into its named accounts (the default entry kept\n/// only per `show_default_account`). `active` stays listed even when\n/// unconfigured — same rule the per-vendor list always had.\nfunc vendorEntries(active: String) -> [MenuEntry] {\n var out: [MenuEntry] = []\n for v in VENDOR_AUTH where vendorEnabled(v) {\n if v.id == \"anthropic\" {\n let labels = claudeAccountLabels()\n let showDefault = showDefaultClaudeAccount(\n configValue: configValueTOML(\"anthropic\", \"show_default_account\"),\n hasAccounts: !labels.isEmpty)\n if showDefault && (v.id == active || vendorConfigured(v)) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n for label in labels {\n out.append(MenuEntry(id: ACCOUNT_ID_PREFIX + label, name: \"Claude · \\(label)\"))\n }\n } else if v.id == active || vendorConfigured(v) {\n out.append(MenuEntry(id: v.id, name: v.name))\n }\n }\n return out\n}\n\n/// Display name for any selectable id (base vendor, account, or overview).\nfunc entryDisplayName(_ id: String) -> String {\n if id == \"overview\" { return \"Visão geral\" }\n if let label = accountLabel(of: id) { return \"Claude · \\(label)\" }\n return VENDOR_AUTH.first { $0.id == id }?.name ?? id\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker. Claude\n // accounts appear as their `anthropic@<label>` pseudo-ids, same as the\n // \"Trocar vendor\" submenu.\n private var vendors: [String] {\n var ids = VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n let labels = claudeAccountLabels()\n if let at = ids.firstIndex(of: \"anthropic\") {\n ids.insert(contentsOf: labels.map { ACCOUNT_ID_PREFIX + $0 }, at: at + 1)\n }\n return ids\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\n\n/// Default shortcut: `\\` (kVK_ANSI_Backslash) with Command+Option. `[ui]` on\n/// Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n NotificationCenter.default.post(name: swapHotKeyNotification, object: nil)\n return noErr\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor); hidden in single-vendor mode.\n // A fixed pool avoids rebuilding the menu (which would disrupt an open menu).\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n installSwapHotKey()\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n removeSwapHotKey()\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n if !swapHotKeyHandlerInstalled {\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n InstallEventHandler(GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n swapHotKeyHandlerInstalled = true\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: 1) // 'AIBB'\n RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &swapHotKeyRef)\n }\n\n func removeSwapHotKey() {\n if let ref = swapHotKeyRef {\n UnregisterEventHotKey(ref)\n swapHotKeyRef = nil\n }\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// The swap-shortcut ring: every configured entry (vendors *and* Claude\n /// accounts) plus the synthetic \"overview\" target, so ⌥⌘\\ cycles them all.\n func swapCycleIds() -> [String] {\n var ids = vendorEntries(active: VENDOR).map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per possible vendor for Overview mode. ponytail: 12 ≥\n // the vendor count; bump if VENDOR_AUTH ever grows past it.\n for _ in 0..<12 {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = entryDisplayName(VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: vendor) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n // vendorEntries(active: \"\") keeps only configured entries — the\n // active-vendor courtesy slot has no place in an all-vendors sweep.\n let entries = vendorEntries(active: \"\")\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in entries {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = vendorArgs(for: v.id) + [\"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: baseVendorId(v.id))\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// Compact bar label for a vendor name. Usually the first word; for a\n /// Claude account (\"Claude · gmail\") the account label is the part that\n /// distinguishes entries, so use it instead of the shared \"Claude\".\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let base = name.range(of: \" · \").map { String(name[$0.upperBound...]) }\n ?? String(name.split(separator: \" \").first ?? Substring(name))\n return base.count <= maxLen ? base : String(base.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 2) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String)] = items.compactMap {\n guard let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\") }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\")\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 2\n let t = NSMutableAttributedString()\n if heads.count <= barsMax {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n let sorted = heads.sorted { $0.pct > $1.pct }\n for (i, e) in sorted.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n }\n if sorted.count > maxN { t.append(run(\" +\\(sorted.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to jump straight to that vendor's detail view.\n it.representedObject = item.id\n it.target = self\n it.action = #selector(switchVendor(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: baseVendorId(VENDOR)) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n // With several Claude accounts the plan alone (\"Claude Max 20x\") no\n // longer says WHICH account this is — suffix the label.\n let plan = s.plan.isEmpty ? \"AI Usage\" : s.plan\n let header = accountLabel(of: VENDOR).map { \"\\(plan) · \\($0)\" } ?? plan\n headerItem.attributedTitle = run(header, .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let entries = vendorEntries(active: active)\n if entries.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in entries {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "9c20cd3c5be8eff5c34fc5fd8a4647e7f430059c": {"t": 1785750151.6711721, "n": 77227, "h": "d079b8227315a7da", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n @AppStorage(\"launchAtLogin\") private var launchAtLogin = false\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker.\n private var vendors: [String] {\n VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Sistema\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Iniciar no login\", isOn: $launchAtLogin)\n .onChange(of: launchAtLogin) { value in setLaunchAtLogin(value) }\n Text(\"Instala um LaunchAgent que sobe o app ao entrar na sua conta.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n }\n}\n\n// ─── Launch at login (macOS LaunchAgent) ─────────────────────────────────\n//\n// The app is an unbundled single binary, so the modern SMAppService.mainApp\n// API (which needs a bundle id) doesn't apply. A per-user LaunchAgent is the\n// portable way to start an unbundled executable at login — the same mechanism\n// install-agent.sh sets up, now toggleable from Preferences.\nlet LAUNCH_AGENT_LABEL = \"com.akitaonrails.ai-usagebar-menubar\"\n\nfunc launchAgentPlistPath() -> String {\n \"\\(NSHomeDirectory())/Library/LaunchAgents/\\(LAUNCH_AGENT_LABEL).plist\"\n}\n\n/// Absolute path of the running executable, for the LaunchAgent to relaunch.\nfunc selfExecutablePath() -> String {\n if let p = Bundle.main.executablePath, !p.isEmpty { return p }\n let arg0 = CommandLine.arguments.first ?? \"ai-usagebar-menubar\"\n if arg0.hasPrefix(\"/\") { return arg0 }\n return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)\n .appendingPathComponent(arg0).standardized.path\n}\n\n/// Install (or remove) the login LaunchAgent by managing its plist file.\n/// Building the plist via PropertyListSerialization sidesteps the XML-escaping\n/// the shell installer has to do by hand.\n///\n/// Deliberately no `launchctl load`/`unload`: the app is already running when\n/// this is toggled, so `RunAtLoad` on load would spawn a *second* copy, and\n/// `unload` on disable would SIGTERM this very process (quitting the app the\n/// user is still using) if it happened to be the launchd-managed one. launchd\n/// loads every agent in ~/Library/LaunchAgents at the next login, so writing\n/// (or removing) the file is all that's needed for a start-at-login toggle.\nfunc setLaunchAtLogin(_ enabled: Bool) {\n let path = launchAgentPlistPath()\n if enabled {\n let dir = (path as NSString).deletingLastPathComponent\n try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)\n let plist: [String: Any] = [\n \"Label\": LAUNCH_AGENT_LABEL,\n \"ProgramArguments\": [selfExecutablePath()],\n \"RunAtLoad\": true,\n \"ProcessType\": \"Interactive\",\n ]\n if let data = try? PropertyListSerialization.data(\n fromPropertyList: plist, format: .xml, options: 0) {\n try? data.write(to: URL(fileURLWithPath: path))\n }\n } else {\n try? FileManager.default.removeItem(atPath: path)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\n\n/// Default shortcut: `\\` (kVK_ANSI_Backslash) with Command+Option. `[ui]` on\n/// Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n NotificationCenter.default.post(name: swapHotKeyNotification, object: nil)\n return noErr\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor); hidden in single-vendor mode.\n // A fixed pool avoids rebuilding the menu (which would disrupt an open menu).\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n installSwapHotKey()\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n removeSwapHotKey()\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n if !swapHotKeyHandlerInstalled {\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n InstallEventHandler(GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n swapHotKeyHandlerInstalled = true\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: 1) // 'AIBB'\n RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &swapHotKeyRef)\n }\n\n func removeSwapHotKey() {\n if let ref = swapHotKeyRef {\n UnregisterEventHotKey(ref)\n swapHotKeyRef = nil\n }\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// The swap-shortcut ring: every configured vendor plus the synthetic\n /// \"overview\" target, so ⌥⌘\\ cycles all providers *and* the overview.\n func swapCycleIds() -> [String] {\n var ids = VENDOR_AUTH\n .filter { vendorEnabled($0) && ($0.id == VENDOR || vendorConfigured($0)) }\n .map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per possible vendor for Overview mode. ponytail: 12 ≥\n // the vendor count; bump if VENDOR_AUTH ever grows past it.\n for _ in 0..<12 {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = VENDOR == \"overview\"\n ? \"Visão geral\"\n : (VENDOR_AUTH.first { $0.id == VENDOR }?.name ?? VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = [\"--vendor\", vendor, \"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n let vendors = VENDOR_AUTH.filter { vendorEnabled($0) && vendorConfigured($0) }\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in vendors {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = [\"--vendor\", v.id, \"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: v.id)\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// First word of a vendor name, clipped to `maxLen` — a compact bar label.\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let first = String(name.split(separator: \" \").first ?? Substring(name))\n return first.count <= maxLen ? first : String(first.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 2) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String)] = items.compactMap {\n guard let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\") }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\")\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 2\n let t = NSMutableAttributedString()\n if heads.count <= barsMax {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n let sorted = heads.sorted { $0.pct > $1.pct }\n for (i, e) in sorted.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n }\n if sorted.count > maxN { t.append(run(\" +\\(sorted.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to jump straight to that vendor's detail view.\n it.representedObject = item.id\n it.target = self\n it.action = #selector(switchVendor(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: VENDOR) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n headerItem.attributedTitle = run(s.plan.isEmpty ? \"AI Usage\" : s.plan,\n .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let configured = VENDOR_AUTH.filter { vendorEnabled($0) && ($0.id == active || vendorConfigured($0)) }\n if configured.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in configured {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "302fece927741c801d2133118c9bac5233f3acf5": {"t": 1785750153.145734, "n": 55817, "h": "97ec3f0d72bab884", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\nfunc keychainHasClaude() -> Bool {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n do { try p.run(); p.waitUntilExit(); return p.terminationStatus == 0 } catch { return false }\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n @AppStorage(\"launchAtLogin\") private var launchAtLogin = false\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker.\n private var vendors: [String] {\n VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Sistema\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Iniciar no login\", isOn: $launchAtLogin)\n .onChange(of: launchAtLogin) { value in setLaunchAtLogin(value) }\n Text(\"Instala um LaunchAgent que sobe o app ao entrar na sua conta.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n }\n}\n\n// ─── Launch at login (macOS LaunchAgent) ─────────────────────────────────\n//\n// The app is an unbundled single binary, so the modern SMAppService.mainApp\n// API (which needs a bundle id) doesn't apply. A per-user LaunchAgent is the\n// portable way to start an unbundled executable at login — the same mechanism\n// install-agent.sh sets up, now toggleable from Preferences.\nlet LAUNCH_AGENT_LABEL = \"com.akitaonrails.ai-usagebar-menubar\"\n\nfunc launchAgentPlistPath() -> String {\n \"\\(NSHomeDirectory())/Library/LaunchAgents/\\(LAUNCH_AGENT_LABEL).plist\"\n}\n\n/// Absolute path of the running executable, for the LaunchAgent to relaunch.\nfunc selfExecutablePath() -> String {\n if let p = Bundle.main.executablePath, !p.isEmpty { return p }\n let arg0 = CommandLine.arguments.first ?? \"ai-usagebar-menubar\"\n if arg0.hasPrefix(\"/\") { return arg0 }\n return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)\n .appendingPathComponent(arg0).standardized.path\n}\n\n/// Install (or remove) the login LaunchAgent by managing its plist file.\n/// Building the plist via PropertyListSerialization sidesteps the XML-escaping\n/// the shell installer has to do by hand.\n///\n/// Deliberately no `launchctl load`/`unload`: the app is already running when\n/// this is toggled, so `RunAtLoad` on load would spawn a *second* copy, and\n/// `unload` on disable would SIGTERM this very process (quitting the app the\n/// user is still using) if it happened to be the launchd-managed one. launchd\n/// loads every agent in ~/Library/LaunchAgents at the next login, so writing\n/// (or removing) the file is all that's needed for a start-at-login toggle.\nfunc setLaunchAtLogin(_ enabled: Bool) {\n let path = launchAgentPlistPath()\n if enabled {\n let dir = (path as NSString).deletingLastPathComponent\n try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)\n let plist: [String: Any] = [\n \"Label\": LAUNCH_AGENT_LABEL,\n \"ProgramArguments\": [selfExecutablePath()],\n \"RunAtLoad\": true,\n \"ProcessType\": \"Interactive\",\n ]\n if let data = try? PropertyListSerialization.data(\n fromPropertyList: plist, format: .xml, options: 0) {\n try? data.write(to: URL(fileURLWithPath: path))\n }\n } else {\n try? FileManager.default.removeItem(atPath: path)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n if let s = lastSnapshot { renderPanel(s); renderMenu(s) }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] _, _ in\n DispatchQueue.main.async { self?.rerenderAppearance() }\n }\n }\n\n func rerenderAppearance() {\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = [\"--vendor\", vendor, \"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: VENDOR) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(\"5h\", session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(\"7d\", weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(s.plan.isEmpty ? \"AI Usage\" : s.plan,\n .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", \"Session\", session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", \"Weekly\", weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let configured = VENDOR_AUTH.filter { vendorEnabled($0) && ($0.id == active || vendorConfigured($0)) }\n if configured.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in configured {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "8a8b8b5e13cfd50011b5a18129750f18747ae788": {"t": 1785750154.5446572, "n": 74215, "h": "45e2f4f565565d9d", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit};;{cursor_total_pct}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n /// Cursor's combined \"included total usage\" headline (`totalPercentUsed`),\n /// the single number its dashboard shows across both pools. nil for others.\n var cursorTotalPct: Int? = nil\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\",\n cursorTotalPct: isCursor ? n(27) : nil)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\n// Cached: the check spawns a `security` subprocess, and it is consulted from the\n// menu-rebuild path (main thread). Signed-in state doesn't change within a run,\n// so compute it at most once — a restart picks up a fresh login.\nvar keychainClaudeCache: Bool?\nfunc keychainHasClaude() -> Bool {\n if let cached = keychainClaudeCache { return cached }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n let result: Bool\n do { try p.run(); p.waitUntilExit(); result = p.terminationStatus == 0 } catch { result = false }\n keychainClaudeCache = result\n return result\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker.\n private var vendors: [String] {\n VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\n\n/// Default shortcut: `\\` (kVK_ANSI_Backslash) with Command+Option. `[ui]` on\n/// Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n NotificationCenter.default.post(name: swapHotKeyNotification, object: nil)\n return noErr\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n /// Last resolved light/dark name, so the appearance observer ignores the\n /// layout-driven KVO fires that don't actually change the theme.\n var lastAppearanceName: NSAppearance.Name?\n /// Tracks the active vendor so `settingsChanged` can tell a vendor swap (show\n /// Loading) apart from an unrelated preference change (repaint from cache).\n var lastVendor = \"\"\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor); hidden in single-vendor mode.\n // A fixed pool avoids rebuilding the menu (which would disrupt an open menu).\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, id: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n lastVendor = VENDOR // so the first settingsChanged isn't mistaken for a swap\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n installSwapHotKey()\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n removeSwapHotKey()\n let enabled = DEF.bool(forKey: \"swapShortcutEnabled\")\n // Mirror the swap shortcut as a hint on the \"Trocar vendor\" item. A native\n // keyEquivalent is suppressed by the submenu's disclosure arrow, so paint\n // the hint into the title instead (right-aligned, dimmed). Cleared when off.\n if enabled {\n // Inline, dimmed hint right after the label. A right-aligned column\n // (like the other items' ⌘R) isn't possible here: the submenu arrow\n // takes the trailing slot, and a fixed tab stop would over-widen the\n // menu whenever the wide overview rows are hidden.\n let font = NSFont.menuFont(ofSize: 0)\n let s = NSMutableAttributedString(\n string: \"Trocar vendor \", attributes: [.font: font])\n s.append(NSAttributedString(string: \"⌥⌘\\\\\", attributes: [\n .font: font, .foregroundColor: NSColor.tertiaryLabelColor,\n ]))\n vendorSubmenuItem.attributedTitle = s\n } else {\n vendorSubmenuItem.attributedTitle = NSAttributedString(string: \"Trocar vendor\")\n }\n guard enabled else { return }\n if !swapHotKeyHandlerInstalled {\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n InstallEventHandler(GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n swapHotKeyHandlerInstalled = true\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: 1) // 'AIBB'\n RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &swapHotKeyRef)\n }\n\n func removeSwapHotKey() {\n if let ref = swapHotKeyRef {\n UnregisterEventHotKey(ref)\n swapHotKeyRef = nil\n }\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// The swap-shortcut ring: every configured vendor plus the synthetic\n /// \"overview\" target, so ⌥⌘\\ cycles all providers *and* the overview.\n func swapCycleIds() -> [String] {\n var ids = VENDOR_AUTH\n .filter { vendorEnabled($0) && ($0.id == VENDOR || vendorConfigured($0)) }\n .map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per possible vendor for Overview mode. ponytail: 12 ≥\n // the vendor count; bump if VENDOR_AUTH ever grows past it.\n for _ in 0..<12 {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n // A vendor swap re-fetches, which takes a moment; keeping the previous\n // vendor's view up reads as a freeze. Show a Loading placeholder at once.\n let vendorChanged = VENDOR != lastVendor\n lastVendor = VENDOR\n if vendorChanged {\n showLoading()\n } else if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n /// Instant feedback for a vendor swap: replace the whole view with a Loading\n /// placeholder naming the target, until its data arrives.\n func showLoading() {\n lastSnapshot = nil\n lastOverview = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let name = VENDOR == \"overview\"\n ? \"Visão geral\"\n : (VENDOR_AUTH.first { $0.id == VENDOR }?.name ?? VENDOR)\n statusItem.button?.attributedTitle = run(\"\\(name) …\", menuBarTextColor(appearance))\n headerItem.attributedTitle = run(\"\\(name) · carregando…\", .labelColor,\n NSFont.boldSystemFont(ofSize: 13))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n lastAppearanceName = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] btn, _ in\n // The KVO fires on every layout pass, not only on a real light↔dark\n // flip — and rendering (which relays out the button) would retrigger\n // it, spinning the main thread. Act only when the theme truly changes.\n let name = btn.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua])\n DispatchQueue.main.async {\n guard let self, self.lastAppearanceName != name else { return }\n self.lastAppearanceName = name\n self.rerenderAppearance()\n }\n }\n }\n\n func rerenderAppearance() {\n // Appearance only changes colors; the configured-vendor set is unchanged,\n // so repaint without rebuilding the (subprocess-touching) submenu.\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov, rebuildSubmenu: false); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = [\"--vendor\", vendor, \"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n let vendors = VENDOR_AUTH.filter { vendorEnabled($0) && vendorConfigured($0) }\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, id: String, snap: Snapshot?)] = []\n for v in vendors {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = [\"--vendor\", v.id, \"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: v.id)\n }\n results.append((name: v.name, id: v.id, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview.\n /// Cursor: the combined \"included total usage\" headline (both pools as one).\n /// Everyone else: the most-exhausted of its windows — for Anthropic that is\n /// the biggest of 5h, weekly, and the scoped model bar (Fable). Falls back to\n /// the $ budget when a vendor has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let t = s.cursorTotalPct {\n return (t, \"\\(t)%\", s.weekly?.reset ?? s.session?.reset, nil)\n }\n let windows = [s.session, s.weekly, s.sonnet].compactMap { $0 }\n if let w = windows.max(by: { $0.pct < $1.pct }) {\n return (w.pct, \"\\(w.pct)%\", w.reset, w.elapsed)\n }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n /// First word of a vendor name, clipped to `maxLen` — a compact bar label.\n func ovLabel(_ name: String, _ maxLen: Int) -> String {\n let first = String(name.split(separator: \" \").first ?? Substring(name))\n return first.count <= maxLen ? first : String(first.prefix(maxLen))\n }\n\n /// Status-bar summary for overview mode: every vendor at once. Mini bar per\n /// vendor when few; worst-first numbers (capped, with `+K` overflow) when\n /// many. Tunable via `[ui] overview_menubar_bars_max` (bar↔number threshold,\n /// default 2) and `overview_menubar_max` (how many numbers fit, default 4).\n func overviewBarTitle(_ items: [(name: String, id: String, snap: Snapshot?)],\n _ appearance: NSAppearance) -> NSAttributedString {\n let secondary = menuBarTextColor(appearance, secondary: true)\n let heads: [(name: String, pct: Int, elapsed: Int?, value: String)] = items.compactMap {\n guard let s = $0.snap else { return nil }\n if let cb = s.creditBalance { return ($0.name, -1, nil, \"cr \\(cb)\") }\n let h = overviewHeadline(s)\n return ($0.name, h.pct, h.elapsed, \"\\(h.pct)%\")\n }\n guard !heads.isEmpty else { return run(\"ovr\", secondary) }\n\n let barsMax = Int(configValueTOML(\"ui\", \"overview_menubar_bars_max\") ?? \"\") ?? 2\n let t = NSMutableAttributedString()\n if heads.count <= barsMax {\n for (i, e) in heads.enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 6)) \", secondary))\n if e.pct >= 0 {\n t.append(run(\"\\(e.pct)% \", colorForPct(e.pct)))\n t.append(progressAttr(pct: e.pct, width: BAR_WIDTH, elapsed: e.elapsed,\n appearance: appearance))\n } else {\n t.append(run(e.value, .labelColor)) // credit balance\n }\n }\n } else {\n let maxN = Int(configValueTOML(\"ui\", \"overview_menubar_max\") ?? \"\") ?? 4\n let sorted = heads.sorted { $0.pct > $1.pct }\n for (i, e) in sorted.prefix(maxN).enumerated() {\n if i > 0 { t.append(run(\" \", secondary)) }\n t.append(run(\"\\(ovLabel(e.name, 3)) \", secondary))\n t.append(run(e.value, e.pct >= 0 ? colorForPct(e.pct) : .labelColor))\n }\n if sorted.count > maxN { t.append(run(\" +\\(sorted.count - maxN)\", secondary)) }\n }\n return t\n }\n\n func renderOverview(_ items: [(name: String, id: String, snap: Snapshot?)],\n rebuildSubmenu: Bool = true) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n\n // Rows render in a monospaced font, so fixed character widths line the\n // columns up. Pre-compute the widest headline pct and reset so both can be\n // right-aligned — the reset then ends flush at the line's right edge.\n let nameW = 14\n var pctW = 0, resetW = 0\n for item in items.prefix(overviewRows.count) {\n guard let s = item.snap, s.creditBalance == nil else { continue }\n let h = overviewHeadline(s)\n pctW = max(pctW, h.value.count)\n if let r = h.reset, !r.isEmpty { resetW = max(resetW, r.count) }\n }\n func rpad(_ s: String, _ w: Int) -> String {\n String(repeating: \" \", count: max(0, w - s.count)) + s\n }\n\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n // Click a row to jump straight to that vendor's detail view.\n it.representedObject = item.id\n it.target = self\n it.action = #selector(switchVendor(_:))\n let a = NSMutableAttributedString()\n let fitted = item.name.count > nameW\n ? String(item.name.prefix(nameW - 1)) + \"…\"\n : item.name.padding(toLength: nameW, withPad: \" \", startingAt: 0)\n a.append(run(fitted + \" \", .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(rpad(h.value, pctW))\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty {\n a.append(run(\" ↺ \\(rpad(r, resetW))\", .secondaryLabelColor))\n }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: every vendor at once (mini bars when few, numbers when\n // many). The dropdown always holds the full per-vendor list.\n statusItem.button?.attributedTitle = overviewBarTitle(items, appearance)\n if rebuildSubmenu { rebuildVendorSubmenu() }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: VENDOR) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n headerItem.attributedTitle = run(s.plan.isEmpty ? \"AI Usage\" : s.plan,\n .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let configured = VENDOR_AUTH.filter { vendorEnabled($0) && ($0.id == active || vendorConfigured($0)) }\n if configured.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in configured {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "170a7888f3a8e45d3357e7ffd50508a967a15193": {"t": 1785750155.9173868, "n": 66602, "h": "4e5b631e1de494e2", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\")\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\nfunc keychainHasClaude() -> Bool {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n do { try p.run(); p.waitUntilExit(); return p.terminationStatus == 0 } catch { return false }\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker.\n private var vendors: [String] {\n VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\n\n/// Default shortcut: `\\` (kVK_ANSI_Backslash) with Command+Option. `[ui]` on\n/// Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n NotificationCenter.default.post(name: swapHotKeyNotification, object: nil)\n return noErr\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Overview mode fills these (one per vendor); hidden in single-vendor mode.\n // A fixed pool avoids rebuilding the menu (which would disrupt an open menu).\n var overviewRows: [NSMenuItem] = []\n /// Last overview fetch, so a settings/appearance change re-renders it without\n /// flashing the stale single-vendor snapshot.\n var lastOverview: [(name: String, snap: Snapshot?)]?\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n installSwapHotKey()\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n removeSwapHotKey()\n guard DEF.bool(forKey: \"swapShortcutEnabled\") else { return }\n if !swapHotKeyHandlerInstalled {\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n InstallEventHandler(GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n swapHotKeyHandlerInstalled = true\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: 1) // 'AIBB'\n RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &swapHotKeyRef)\n }\n\n func removeSwapHotKey() {\n if let ref = swapHotKeyRef {\n UnregisterEventHotKey(ref)\n swapHotKeyRef = nil\n }\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n if let next = nextVendorId(current: VENDOR, in: swapCycleIds()) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n /// The swap-shortcut ring: every configured vendor plus the synthetic\n /// \"overview\" target, so ⌥⌘\\ cycles all providers *and* the overview.\n func swapCycleIds() -> [String] {\n var ids = VENDOR_AUTH\n .filter { vendorEnabled($0) && ($0.id == VENDOR || vendorConfigured($0)) }\n .map { $0.id }\n ids.append(\"overview\")\n return ids\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n // One hidden slot per possible vendor for Overview mode. ponytail: 12 ≥\n // the vendor count; bump if VENDOR_AUTH ever grows past it.\n for _ in 0..<12 {\n let it = NSMenuItem()\n it.isHidden = true\n overviewRows.append(it)\n menu.addItem(it)\n }\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n if VENDOR == \"overview\" {\n if let ov = lastOverview { renderOverview(ov) }\n } else if let s = lastSnapshot {\n renderPanel(s); renderMenu(s)\n }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] _, _ in\n DispatchQueue.main.async { self?.rerenderAppearance() }\n }\n }\n\n func rerenderAppearance() {\n if VENDOR == \"overview\", let ov = lastOverview { renderOverview(ov); return }\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n if vendor == \"overview\" {\n refreshOverview(bin: bin, generation: generation)\n return\n }\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = [\"--vendor\", vendor, \"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n /// Overview mode: fetch every configured vendor and render one compact row\n /// each. Sequential on purpose — reads are cache-first, and serializing\n /// respects the per-vendor cache flock and avoids an OAuth-refresh 429 burst.\n /// ponytail: parallelize only if it ever feels slow.\n func refreshOverview(bin: String, generation: Int) {\n let vendors = VENDOR_AUTH.filter { vendorEnabled($0) && vendorConfigured($0) }\n DispatchQueue.global(qos: .utility).async { [weak self] in\n var results: [(name: String, snap: Snapshot?)] = []\n for v in vendors {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = [\"--vendor\", v.id, \"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch { out = \"\" }\n watchdog.cancel()\n var snap: Snapshot?\n if let data = out.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String {\n snap = parse(text, vendor: v.id)\n }\n results.append((name: v.name, snap: snap))\n }\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n guard VENDOR == \"overview\" else { return }\n me.renderOverview(results)\n }\n }\n }\n }\n\n /// The single most-relevant number for a vendor in the overview: the worse of\n /// its two windows, or its $ budget when it has no time windows.\n func overviewHeadline(_ s: Snapshot) -> (pct: Int, value: String, reset: String?, elapsed: Int?) {\n if let a = s.session, let b = s.weekly {\n return a.pct >= b.pct\n ? (a.pct, \"\\(a.pct)%\", a.reset, a.elapsed)\n : (b.pct, \"\\(b.pct)%\", b.reset, b.elapsed)\n }\n if let a = s.session ?? s.weekly { return (a.pct, \"\\(a.pct)%\", a.reset, a.elapsed) }\n if let e = s.extra { return (e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n return (0, \"—\", nil, nil)\n }\n\n func renderOverview(_ items: [(name: String, snap: Snapshot?)]) {\n lastSnapshot = nil\n lastOverview = items\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(\"Visão geral\", .labelColor, NSFont.boldSystemFont(ofSize: 13))\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] { rows[key]?.isHidden = true }\n\n var worstPct = -1\n var worstName = \"\"\n for (i, item) in items.enumerated() where i < overviewRows.count {\n let it = overviewRows[i]\n it.isHidden = false\n let a = NSMutableAttributedString()\n let label = item.name.count < 16\n ? item.name.padding(toLength: 16, withPad: \" \", startingAt: 0)\n : item.name\n a.append(run(label, .labelColor))\n if let s = item.snap {\n if let cb = s.creditBalance {\n a.append(run(\"cr \\(cb)\", .labelColor))\n } else {\n let h = overviewHeadline(s)\n a.append(progressAttr(pct: h.pct, width: MENU_BAR_W, elapsed: h.elapsed,\n menu: true, appearance: appearance))\n a.append(run(\" \\(h.value)\", colorForPct(h.pct)))\n if let r = h.reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n if h.pct > worstPct { worstPct = h.pct; worstName = item.name }\n }\n } else {\n a.append(run(\"—\", .secondaryLabelColor))\n }\n it.attributedTitle = a\n }\n for i in items.count..<overviewRows.count { overviewRows[i].isHidden = true }\n\n // Menu-bar title: the single most-exhausted quota — the one glanceable\n // fact a tiny bar can carry. The dropdown holds the full list.\n let secondary = menuBarTextColor(appearance, secondary: true)\n if worstPct >= 0 {\n let t = NSMutableAttributedString()\n t.append(run(\"\\(worstPct)% \", colorForPct(worstPct)))\n t.append(run(String(worstName.split(separator: \" \").first ?? Substring(worstName)), secondary))\n statusItem.button?.attributedTitle = t\n } else {\n statusItem.button?.attributedTitle = run(\"ovr\", secondary)\n }\n rebuildVendorSubmenu()\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: VENDOR) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n for it in overviewRows { it.isHidden = true }\n headerItem.attributedTitle = run(s.plan.isEmpty ? \"AI Usage\" : s.plan,\n .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let configured = VENDOR_AUTH.filter { vendorEnabled($0) && ($0.id == active || vendorConfigured($0)) }\n if configured.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in configured {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n // Synthetic overview target — same one the ⌥⌘\\ ring ends on.\n vendorSubmenu.addItem(.separator())\n let ov = NSMenuItem(title: \"Visão geral\", action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n ov.target = self\n ov.representedObject = \"overview\"\n ov.state = (active == \"overview\") ? .on : .off\n vendorSubmenu.addItem(ov)\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n for it in overviewRows { it.isHidden = true }\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "38b5a70d3ca7cddada3062c0c4f26c95c47a0f0d": {"t": 1785750157.3530948, "n": 59623, "h": "fe5156d568568b7f", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n /// Overridable labels for the session/weekly bars. Default to the\n /// time-window names; a vendor whose two windows aren't time-based (Cursor:\n /// \"Cursor Models\" / \"Other Models\") sets its own. Tags are the 2-char\n /// prefixes shown on the compact status bar. Defaulted so every other\n /// vendor's construction is unchanged.\n var sessionTag: String = \"5h\"\n var weeklyTag: String = \"7d\"\n var sessionLabel: String = \"Session\"\n var weeklyLabel: String = \"Weekly\"\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n // Cursor carries its two included-usage pools on the session/weekly\n // aliases (session = Cursor Models, weekly = Other Models — both real, not\n // time windows), so relabel those bars rather than call them \"Session\"/\n // \"Weekly\". Every other vendor keeps the default time-window labels.\n let isCursor = vendor == \"cursor\"\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra,\n sessionTag: isCursor ? \"auto\" : \"5h\",\n weeklyTag: isCursor ? \"premium\" : \"7d\",\n sessionLabel: isCursor ? \"Cursor Models\" : \"Session\",\n weeklyLabel: isCursor ? \"Other Models\" : \"Weekly\")\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n // Cursor has no API key: the binary reads the session token the Cursor IDE\n // wrote to its own state.vscdb. `kind: \"local\"` marks the \"configured =\n // signed in to the app\" case (like Antigravity in the GNOME extension),\n // with no login CLI or env var of its own.\n VendorAuth(id: \"cursor\", name: \"Cursor\", kind: \"local\", cli: \"\", login: \"\", pkg: \"\", env: \"\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\", \"cursor\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\nfunc keychainHasClaude() -> Bool {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n do { try p.run(); p.waitUntilExit(); return p.terminationStatus == 0 } catch { return false }\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if v.id == \"cursor\" {\n // Configured == signed in to the Cursor IDE, i.e. its state DB exists.\n // Honor a [cursor] db_path override the same way the binary does.\n let dbPath = configValueTOML(\"cursor\", \"db_path\")\n ?? \"\\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb\"\n return fm.fileExists(atPath: dbPath)\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\n// Launch a .app by name (e.g. \"Cursor\") via `open -a`, so a local-kind vendor's\n// button can bring the user to the app they need to sign into.\nfunc openApp(_ name: String) {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n p.arguments = [\"-a\", name]\n try? p.run()\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n // Local vendors (Cursor) have no key: \"configured\" means signed in to\n // the app AND [cursor] enabled in config.\n if v.kind == \"local\" {\n return \"⚠ Entre no app Cursor e ative [cursor] no config\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n if v.kind == \"local\" { return \"Abrir Cursor\" }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else if v.kind == \"local\" { openApp(v.name) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker.\n private var vendors: [String] {\n VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\n\n/// Default shortcut: `\\` (kVK_ANSI_Backslash) with Command+Option. `[ui]` on\n/// Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n NotificationCenter.default.post(name: swapHotKeyNotification, object: nil)\n return noErr\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n installSwapHotKey()\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n removeSwapHotKey()\n guard DEF.bool(forKey: \"swapShortcutEnabled\") else { return }\n if !swapHotKeyHandlerInstalled {\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n InstallEventHandler(GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n swapHotKeyHandlerInstalled = true\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: 1) // 'AIBB'\n RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &swapHotKeyRef)\n }\n\n func removeSwapHotKey() {\n if let ref = swapHotKeyRef {\n UnregisterEventHotKey(ref)\n swapHotKeyRef = nil\n }\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n let ids = VENDOR_AUTH\n .filter { vendorEnabled($0) && ($0.id == VENDOR || vendorConfigured($0)) }\n .map { $0.id }\n if let next = nextVendorId(current: VENDOR, in: ids) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n if let s = lastSnapshot { renderPanel(s); renderMenu(s) }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] _, _ in\n DispatchQueue.main.async { self?.rerenderAppearance() }\n }\n }\n\n func rerenderAppearance() {\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = [\"--vendor\", vendor, \"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: VENDOR) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(s.sessionTag, session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(s.weeklyTag, weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(s.plan.isEmpty ? \"AI Usage\" : s.plan,\n .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", s.sessionLabel, session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", s.weeklyLabel, weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let configured = VENDOR_AUTH.filter { vendorEnabled($0) && ($0.id == active || vendorConfigured($0)) }\n if configured.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in configured {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "d14552a454e85a93d3446757eb29cb809509fb60": {"t": 1785750158.7348082, "n": 57068, "h": "b95dbdb0b949d3c0", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\nimport Carbon.HIToolbox // RegisterEventHotKey for the global vendor-swap shortcut\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\nfunc keychainHasClaude() -> Bool {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n do { try p.run(); p.waitUntilExit(); return p.terminationStatus == 0 } catch { return false }\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"swapShortcutEnabled\") private var swapShortcutEnabled = true\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker.\n private var vendors: [String] {\n VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Atalho\") {\n VStack(alignment: .leading, spacing: 4) {\n Toggle(\"Trocar vendor com ⌥⌘\\\\ (atalho global)\", isOn: $swapShortcutEnabled)\n Text(\"Alterna o vendor ativo a partir de qualquer app.\")\n .font(.caption).foregroundColor(.secondary)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\n// ─── Global vendor-swap shortcut (⌥⌘\\) ───────────────────────────────────\n//\n// A menu-bar app has no focused window, so a system-wide shortcut needs a\n// Carbon hot key (RegisterEventHotKey), which fires regardless of focus and —\n// unlike an NSEvent global monitor — needs no Accessibility permission. The\n// Carbon handler is a C callback that can't capture Swift state, so it just\n// posts a notification the delegate observes.\n\nlet swapHotKeyNotification = Notification.Name(\"aiusagebar.swapHotKey\")\n\n/// Default shortcut: `\\` (kVK_ANSI_Backslash) with Command+Option. `[ui]` on\n/// Linux has no equivalent; this is macOS-only.\nlet SWAP_HOTKEY_KEYCODE = UInt32(kVK_ANSI_Backslash)\nlet SWAP_HOTKEY_MODIFIERS = UInt32(cmdKey | optionKey)\n\nprivate func swapHotKeyCallback(\n _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?\n) -> OSStatus {\n NotificationCenter.default.post(name: swapHotKeyNotification, object: nil)\n return noErr\n}\n\n/// The next id in the cycle, wrapping. `current` absent from `ids` (e.g. the\n/// selected vendor was disabled) starts at the first (or last, backward).\n/// Pure + testable — the hot-key handler is not.\nfunc nextVendorId(current: String, in ids: [String], forward: Bool = true) -> String? {\n guard !ids.isEmpty else { return nil }\n let n = ids.count\n let i = ids.firstIndex(of: current) ?? (forward ? -1 : 0)\n let j = forward ? (i + 1) % n : (i - 1 + n) % n\n return ids[j]\n}\n\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var swapHotKeyRef: EventHotKeyRef?\n var swapHotKeyHandlerInstalled = false\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n DEF.register(defaults: [\"swapShortcutEnabled\": true])\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n NotificationCenter.default.addObserver(\n self, selector: #selector(handleSwapHotKey),\n name: swapHotKeyNotification, object: nil)\n installSwapHotKey()\n }\n\n /// (Re)register the global ⌥⌘\\ hot key to match the `swapShortcutEnabled`\n /// preference. Idempotent: always unregisters first.\n func installSwapHotKey() {\n removeSwapHotKey()\n guard DEF.bool(forKey: \"swapShortcutEnabled\") else { return }\n if !swapHotKeyHandlerInstalled {\n var spec = EventTypeSpec(\n eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))\n InstallEventHandler(GetApplicationEventTarget(), swapHotKeyCallback, 1, &spec, nil, nil)\n swapHotKeyHandlerInstalled = true\n }\n let id = EventHotKeyID(signature: OSType(0x4149_4242), id: 1) // 'AIBB'\n RegisterEventHotKey(\n SWAP_HOTKEY_KEYCODE, SWAP_HOTKEY_MODIFIERS, id, GetApplicationEventTarget(), 0,\n &swapHotKeyRef)\n }\n\n func removeSwapHotKey() {\n if let ref = swapHotKeyRef {\n UnregisterEventHotKey(ref)\n swapHotKeyRef = nil\n }\n }\n\n /// Advance the active vendor to the next configured one, wrapping. Fired by\n /// the global hot key; sets `vendor` in defaults, which drives the usual\n /// re-render via `settingsChanged`.\n @objc func handleSwapHotKey() {\n let ids = VENDOR_AUTH\n .filter { vendorEnabled($0) && ($0.id == VENDOR || vendorConfigured($0)) }\n .map { $0.id }\n if let next = nextVendorId(current: VENDOR, in: ids) {\n DEF.set(next, forKey: \"vendor\")\n }\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n // The swap-shortcut toggle lives in defaults too; re-register only when\n // its state and the live registration disagree (avoids churn on every\n // vendor switch, which itself writes defaults).\n if DEF.bool(forKey: \"swapShortcutEnabled\") != (swapHotKeyRef != nil) {\n installSwapHotKey()\n }\n if let s = lastSnapshot { renderPanel(s); renderMenu(s) }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] _, _ in\n DispatchQueue.main.async { self?.rerenderAppearance() }\n }\n }\n\n func rerenderAppearance() {\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = [\"--vendor\", vendor, \"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: VENDOR) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(\"5h\", session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(\"7d\", weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(s.plan.isEmpty ? \"AI Usage\" : s.plan,\n .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", \"Session\", session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", \"Weekly\", weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let configured = VENDOR_AUTH.filter { vendorEnabled($0) && ($0.id == active || vendorConfigured($0)) }\n if configured.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in configured {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "7612f49d4890765b77c770b3fe30da4bdf936659": {"t": 1785750160.913429, "n": 52805, "h": "942b91759fd6e9f2", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: binaries\n // up to 0.16 report vendor_short = \"kmi\" for both Kimi and Moonshot, so\n // keying on vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\nfunc keychainHasClaude() -> Bool {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n do { try p.run(); p.waitUntilExit(); return p.terminationStatus == 0 } catch { return false }\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker.\n private var vendors: [String] {\n VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n if let s = lastSnapshot { renderPanel(s); renderMenu(s) }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] _, _ in\n DispatchQueue.main.async { self?.rerenderAppearance() }\n }\n }\n\n func rerenderAppearance() {\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = [\"--vendor\", vendor, \"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: VENDOR) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(\"5h\", session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(\"7d\", weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(s.plan.isEmpty ? \"AI Usage\" : s.plan,\n .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", \"Session\", session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", \"Weekly\", weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let configured = VENDOR_AUTH.filter { vendorEnabled($0) && ($0.id == active || vendorConfigured($0)) }\n if configured.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in configured {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}, "a300973c4ff76440cd61f190ca872e680fa094aa": {"t": 1785750163.7431033, "n": 52800, "h": "eb308d11d2b8b8bc", "c": "// ai-usagebar-menubar — macOS menu bar app for ai-usagebar.\n//\n// Shows ai-usagebar's 5-hour (session), weekly, and optional extra-usage\n// bars in the macOS menu bar, next to the clock, with a native dropdown and\n// a Preferences window (⌘,). Mirrors the GNOME Shell extension: same binary,\n// same One Dark colors and severity thresholds. Runs as a menu-bar agent.\n//\n// Settings persist in UserDefaults (edit them in Preferences, no rebuild).\n//\n// Build: swiftc -O -parse-as-library ai-usagebar-menubar.swift -o ai-usagebar-menubar\n// (needs the Xcode command-line tools: `xcode-select --install`)\n// Run: ./ai-usagebar-menubar & (or ./install-agent.sh for login start)\n// macOS: 12+ (Monterey) for the Preferences window; menu bar works on 10.15+.\n//\n// First, on the Mac: run `claude` once so the OAuth creds land in the login\n// Keychain — ai-usagebar reads them there (src/anthropic/keychain.rs).\n\nimport Cocoa\nimport SwiftUI\n\n// ─── Settings (persisted in UserDefaults; edit in Preferences) ───────────\nlet DEF = UserDefaults.standard\n\nlet SETTINGS_DEFAULTS: [String: Any] = [\n \"vendor\": \"anthropic\",\n \"interval\": 30.0,\n \"barWidth\": 8,\n \"showSession\": true,\n \"showWeekly\": true,\n \"showExtra\": false,\n \"showPercent\": true,\n \"showBars\": true,\n \"showMeta\": true,\n \"barStyle\": \"block\",\n \"colorLow\": \"#98c379\",\n \"colorMid\": \"#e5c07b\",\n \"colorHigh\": \"#d19a66\",\n \"colorCritical\": \"#e06c75\",\n \"colorEmpty\": \"#3e4451\",\n \"binaryPath\": \"\",\n]\n\nvar VENDOR: String { DEF.string(forKey: \"vendor\") ?? \"anthropic\" }\nvar INTERVAL: Double { let v = DEF.double(forKey: \"interval\"); return v > 0 ? v : 30 }\n/// Upper bound on one `ai-usagebar` invocation. It can block on the cache\n/// flock (up to 15s) and then refresh OAuth over the network, so without a\n/// bound a hung run holds a worker indefinitely and the panel simply stops\n/// updating with no explanation. Matches the GNOME extension's own timeout.\nlet REFRESH_TIMEOUT: Double = 45\nvar BAR_WIDTH: Int { max(4, min(20, DEF.integer(forKey: \"barWidth\"))) }\nlet MENU_BAR_W = 14\nvar SHOW_SESSION: Bool { DEF.bool(forKey: \"showSession\") }\nvar SHOW_WEEKLY: Bool { DEF.bool(forKey: \"showWeekly\") }\nvar SHOW_EXTRA: Bool { DEF.bool(forKey: \"showExtra\") }\nvar SHOW_PERCENT: Bool { DEF.bool(forKey: \"showPercent\") }\nvar SHOW_BARS: Bool { DEF.bool(forKey: \"showBars\") }\n// Layout of the progress indicator: \"block\" (default, text bars ░█) or \"ring\"\n// (a Core Graphics arc image). Both honor the meta marker the same way.\nvar BAR_STYLE: String { DEF.string(forKey: \"barStyle\") ?? \"block\" }\nvar COLOR_LOW: String { DEF.string(forKey: \"colorLow\") ?? \"#98c379\" }\nvar COLOR_MID: String { DEF.string(forKey: \"colorMid\") ?? \"#e5c07b\" }\nvar COLOR_HIGH: String { DEF.string(forKey: \"colorHigh\") ?? \"#d19a66\" }\nvar COLOR_CRITICAL: String { DEF.string(forKey: \"colorCritical\") ?? \"#e06c75\" }\nvar COLOR_EMPTY: String { DEF.string(forKey: \"colorEmpty\") ?? \"#3e4451\" }\n// Meta reference: draw a pace marker at the elapsed-time position and flag the\n// over-meta segment of the fill. Off = plain absolute-usage bars, no marker.\nvar SHOW_META: Bool { DEF.bool(forKey: \"showMeta\") }\n// The meta marker is a fixed blue, matching the binary's default theme `marker`\n// color and distinct from the over-pace warning fill.\nlet COLOR_MARKER = \"#61afef\"\nlet POINT_MID_MIN = -10\nlet POINT_CRITICAL_MIN = 10\n\n// The `{scoped_*}` fields (10-12) carry the model-scoped weekly window (e.g.\n// \"Fable\") from the API's `limits[]`; empty on older binaries → the row falls\n// back to the flat `{sonnet_*}` window and the \"Sonnet only\" label. The trailing\n// `*_elapsed` fields (13-15) carry the meta (pace) position; `vendor_short`\n// (16) lets balance-only vendors suppress meaningless quota rows. The balance\n// fields (17-22) carry the per-vendor credits — only the selected vendor's is\n// populated — and the `aapi_*` fields (23-26) carry the Anthropic API headline\n// plus its spend-vs-limit bar. A final literal sentinel absorbs the widget's\n// stale suffix, preserving these fields.\nlet FORMAT = \"{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset};;\" +\n \"{sonnet_pct};;{sonnet_reset};;{extra_pct};;{extra_spent};;{extra_limit};;\" +\n \"{scoped_model};;{scoped_pct};;{scoped_reset};;\" +\n \"{session_elapsed};;{weekly_elapsed};;{scoped_elapsed};;{vendor_short};;{or_balance};;\" +\n \"{ds_balance};;{kilo_balance};;{nv_balance};;{km_balance};;{grok_balance};;\" +\n \"{aapi_headline};;{aapi_pct};;{aapi_spent};;{aapi_limit}\"\n\nlet FORMAT_WITH_SENTINEL = FORMAT + \";;__aiub_end__\"\n\n// ─── Color / text helpers ────────────────────────────────────────────────\nfunc hexColor(_ hex: String) -> NSColor {\n var s = hex\n if s.hasPrefix(\"#\") { s.removeFirst() }\n guard s.count == 6, let v = UInt32(s, radix: 16) else { return .labelColor }\n return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255.0,\n green: CGFloat((v >> 8) & 0xff) / 255.0,\n blue: CGFloat(v & 0xff) / 255.0,\n alpha: 1.0)\n}\n\nfunc colorForPct(_ pct: Int) -> NSColor {\n if pct >= 90 { return hexColor(COLOR_CRITICAL) }\n if pct >= 75 { return hexColor(COLOR_HIGH) }\n if pct >= 50 { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\n// Matches pacing::pace_severity: < -10 low, -10...0 mid, 1...9 high, >= 10 critical.\nfunc colorForDelta(_ delta: Int) -> NSColor {\n if delta >= POINT_CRITICAL_MIN { return hexColor(COLOR_CRITICAL) }\n if delta > 0 { return hexColor(COLOR_HIGH) }\n if delta >= POINT_MID_MIN { return hexColor(COLOR_MID) }\n return hexColor(COLOR_LOW)\n}\n\nfunc menuBarTextColor(_ appearance: NSAppearance, secondary: Bool = false) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n let color = isDark ? NSColor.white : NSColor.black\n return secondary ? color.withAlphaComponent(0.72) : color\n}\n\n// The ring track needs to stay visible over both light and dark menu bars /\n// dropdowns. The user-configured COLOR_EMPTY is a dark charcoal meant for the\n// block bar's ░ glyphs on a light surface; on a dark wallpaper or dark menu bar\n// it vanishes. So the ring track uses a faint white in dark appearance (visible\n// against the dark background) and falls back to COLOR_EMPTY in light\n// appearance, keeping parity with the block bar there.\nfunc ringTrackColor(_ appearance: NSAppearance) -> NSColor {\n let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n return isDark ? NSColor.white.withAlphaComponent(0.25) : hexColor(COLOR_EMPTY)\n}\n\n// A missing reset keeps its row visible but never has a meaningful pace marker.\nfunc markerElapsed(reset: String, elapsed: Int?) -> Int? {\n guard !reset.isEmpty, reset != \"—\" else { return nil }\n return elapsed\n}\n\nlet barFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)\n\nfunc run(_ s: String, _ color: NSColor, _ font: NSFont = barFont) -> NSAttributedString {\n NSAttributedString(string: s, attributes: [.foregroundColor: color, .font: font])\n}\n\n// Block bar. When `elapsed` (0..100) is known and the meta is on, the fill stays\n// in the calm absolute-usage color up to a blue marker at the elapsed position,\n// and only the part that overshoots the meta (how far ahead of pace you are →\n// risk of paid extra usage) is painted in the warning color. Otherwise it's a\n// plain absolute-color bar with no marker.\nfunc barAttr(pct: Int, width: Int, elapsed: Int?) -> NSAttributedString {\n let p = max(0, min(100, pct))\n let filled = Int((Double(p) * Double(width) / 100.0).rounded())\n let out = NSMutableAttributedString()\n\n guard SHOW_META, let elapsedVal = elapsed else {\n out.append(run(String(repeating: \"█\", count: filled), colorForPct(p)))\n out.append(run(String(repeating: \"░\", count: max(0, width - filled)), hexColor(COLOR_EMPTY)))\n return out\n }\n\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(p) // on-track portion → calm absolute color\n let over = colorForDelta(p - e) // excess beyond the meta → pace warning\n var m = Int(Double(e) * Double(width) / 100.0) // floor\n if m > width - 1 { m = width - 1 }\n if m < 0 { m = 0 }\n let preF = min(filled, m)\n let postF = filled > m + 1 ? filled - m - 1 : 0\n let preE = m - preF\n let postE = width - m - 1 - postF\n out.append(run(String(repeating: \"█\", count: max(0, preF)), base))\n out.append(run(String(repeating: \"░\", count: max(0, preE)), hexColor(COLOR_EMPTY)))\n out.append(run(\"│\", hexColor(COLOR_MARKER)))\n out.append(run(String(repeating: \"█\", count: max(0, postF)), over))\n out.append(run(String(repeating: \"░\", count: max(0, postE)), hexColor(COLOR_EMPTY)))\n return out\n}\n\n// Ring indicator (optional layout). A Core Graphics arc whose sweep is the\n// usage fraction, painted in the severity color, over a faint track. When the\n// meta is on, the elapsed position marks a blue tick and the arc beyond it (how\n// far ahead of pace you are) shifts to the pace-warning color — the same idea\n// as the block bar, just radial. The image is rendered as an attachment so it\n// composes in an attributed string alongside the percentage text.\n/// Arc geometry for a ring segment, in degrees for `NSBezierPath.appendArc`.\n/// The ring starts at 12 o'clock (`startRad = -π/2`) and fills clockwise, so a\n/// segment [from, to] spans `[start - 2π·from, start - 2π·to]`. Pure and tested\n/// so the pace-arc regression (overshoot restarting at 12h) cannot return.\nfunc arcAngles(from fromFraction: CGFloat, to toFraction: CGFloat,\n startRad: CGFloat = -.pi / 2) -> (startDeg: CGFloat, endDeg: CGFloat) {\n ((startRad - 2 * .pi * fromFraction) * 180 / .pi,\n (startRad - 2 * .pi * toFraction) * 180 / .pi)\n}\n\nfunc ringImage(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSImage {\n let p = CGFloat(max(0, min(100, pct))) / 100.0\n let img = NSImage(size: NSSize(width: size, height: size))\n img.lockFocus()\n\n let box = NSRect(x: 0, y: 0, width: size, height: size)\n let lw = max(1.6, size * 0.16)\n let inset = lw / 2 + 0.5\n let rect = box.insetBy(dx: inset, dy: inset)\n let start: CGFloat = -.pi / 2\n\n // Track (empty background ring).\n let track = NSBezierPath()\n track.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2, startAngle: 0, endAngle: 360)\n track.lineWidth = lw\n ringTrackColor(appearance).setStroke()\n track.stroke()\n\n // Filled arc. With the meta on, the part behind the pace marker keeps the\n // calm absolute color and the overshoot turns warning; otherwise a single\n // severity-colored sweep. The helper draws [from, to] so the overshoot can\n // continue from the elapsed marker to pct instead of restarting at 12h.\n let drawArc = { (fromFraction: CGFloat, toFraction: CGFloat, color: NSColor) in\n guard toFraction > fromFraction else { return }\n let a = arcAngles(from: fromFraction, to: toFraction, startRad: start)\n let arc = NSBezierPath()\n arc.appendArc(withCenter: CGPoint(x: size / 2, y: size / 2),\n radius: rect.width / 2,\n startAngle: a.startDeg,\n endAngle: a.endDeg,\n clockwise: true)\n arc.lineWidth = lw\n color.setStroke()\n arc.stroke()\n }\n let pInt = max(0, min(100, pct))\n if SHOW_META, let elapsedVal = elapsed {\n let e = max(0, min(100, elapsedVal))\n let base = colorForPct(pInt)\n let over = colorForDelta(pInt - e)\n let eFrac = CGFloat(e) / 100.0\n let boundary = min(p, eFrac)\n drawArc(0, boundary, base)\n if p > eFrac { drawArc(boundary, p, over) }\n // Pace tick at the elapsed position.\n let tickAngle = start - 2 * .pi * eFrac\n let c = CGPoint(x: size / 2, y: size / 2)\n let r = rect.width / 2\n let tick = NSBezierPath()\n tick.move(to: CGPoint(x: c.x + (r - lw) * cos(tickAngle),\n y: c.y + (r - lw) * sin(tickAngle)))\n tick.line(to: CGPoint(x: c.x + (r + lw) * cos(tickAngle),\n y: c.y + (r + lw) * sin(tickAngle)))\n tick.lineWidth = max(1, lw * 0.5)\n hexColor(COLOR_MARKER).setStroke()\n tick.stroke()\n } else {\n drawArc(0, p, colorForPct(pInt))\n }\n img.unlockFocus()\n img.isTemplate = false\n return img\n}\n\nfinal class ColoredAttachmentCell: NSTextAttachmentCell {\n override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {\n guard let image else { return }\n image.draw(in: cellFrame,\n from: .zero,\n operation: .sourceOver,\n fraction: 1.0,\n respectFlipped: true,\n hints: nil)\n }\n}\n\nfunc ringAttr(pct: Int, size: CGFloat, elapsed: Int?, appearance: NSAppearance) -> NSAttributedString {\n let out = NSMutableAttributedString()\n let attachment = NSTextAttachment()\n let image = ringImage(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n attachment.image = image\n attachment.attachmentCell = ColoredAttachmentCell(imageCell: image)\n // Vertically center the ring on the text's cap height rather than sitting it\n // on the baseline: without this the attachment grows upward only, so larger\n // rings drift toward the top of the menu bar. The origin is baseline-relative,\n // so offset by half the gap between the image and the cap height.\n let cap = barFont.capHeight\n let dy = (cap - size) / 2\n attachment.bounds = NSRect(x: 0, y: dy, width: size, height: size)\n out.append(NSAttributedString(attachment: attachment))\n return out\n}\n\n// Dispatches to the block bar or the ring according to the selected layout, so\n// the panel and dropdown render with one call regardless of style. The ring has\n// its own fixed pixel sizes (it does not scale with the block `width`, which is\n// a character count); `menu` picks the larger ring used in dropdown rows. The\n// appearance is threaded through so the ring track can adapt to light/dark.\nfunc progressAttr(pct: Int, width: Int, elapsed: Int?, menu: Bool = false,\n appearance: NSAppearance) -> NSAttributedString {\n if BAR_STYLE == \"ring\" {\n let size: CGFloat = menu ? CGFloat(MENU_BAR_W) + 4 : CGFloat(BAR_WIDTH) + 6\n return ringAttr(pct: pct, size: size, elapsed: elapsed, appearance: appearance)\n }\n return barAttr(pct: pct, width: width, elapsed: elapsed)\n}\n\nfunc resolveBinary(_ name: String) -> String? {\n let fm = FileManager.default\n if name == \"ai-usagebar\" {\n let configured = DEF.string(forKey: \"binaryPath\") ?? \"\"\n if !configured.isEmpty, fm.isExecutableFile(atPath: configured) { return configured }\n }\n let home = NSHomeDirectory()\n for c in [\"\\(home)/.cargo/bin/\\(name)\", \"/opt/homebrew/bin/\\(name)\", \"/usr/local/bin/\\(name)\"]\n where fm.isExecutableFile(atPath: c) {\n return c\n }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n p.arguments = [name]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n let path = String(data: data, encoding: .utf8)?\n .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n if !path.isEmpty && fm.isExecutableFile(atPath: path) { return path }\n } catch {}\n return nil\n}\n\n// ─── Data model ──────────────────────────────────────────────────────────\nstruct Window { let pct: Int; let reset: String; let elapsed: Int? }\nstruct Snapshot {\n let plan: String\n let hasUsageWindows: Bool\n let creditBalance: String?\n let session: Window?\n let weekly: Window?\n /// The per-model weekly bar (model-scoped window, e.g. Fable, or the legacy\n /// flat sonnet window).\n let sonnet: Window?\n /// Label for that bar: the scoped model name (\"Fable\") or \"Sonnet only\".\n let sonnetLabel: String\n let extra: (pct: Int, spent: String, limit: String)?\n}\n\nfunc stripMarkup(_ s: String) -> String {\n // Decode exactly one layer after removing tags. Rust escapes API-controlled\n // labels for Pango; the native surface consumes plain text and must not\n // display those entities literally or reactivate decoded markup.\n s.replacingOccurrences(of: \"<[^>]*>\", with: \"\", options: .regularExpression)\n .replacingOccurrences(of: \"&lt;\", with: \"<\")\n .replacingOccurrences(of: \"&gt;\", with: \">\")\n .replacingOccurrences(of: \"&quot;\", with: \"\\\"\")\n .replacingOccurrences(of: \"&apos;\", with: \"'\")\n .replacingOccurrences(of: \"&amp;\", with: \"&\")\n}\n\nfunc parse(_ text: String, vendor: String) -> Snapshot? {\n let f = stripMarkup(text).components(separatedBy: \";;\")\n guard f.count >= 10 else { return nil }\n func unknownPlaceholder(_ s: String) -> Bool {\n s.hasPrefix(\"{\") && s.hasSuffix(\"}\")\n }\n func t(_ i: Int) -> String {\n guard i < f.count else { return \"\" }\n let v = f[i].trimmingCharacters(in: .whitespaces)\n return unknownPlaceholder(v) ? \"\" : v\n }\n // Do not accept a numeric prefix: a stale suffix such as \"27 ⏸\" is not elapsed.\n func n(_ i: Int) -> Int? {\n let value = t(i)\n guard value.range(of: \"^-?[0-9]+$\", options: .regularExpression) != nil else { return nil }\n return Int(value)\n }\n func quotaWindow(_ pctIndex: Int, _ resetIndex: Int, _ elapsedIndex: Int) -> Window? {\n guard let pct = n(pctIndex), (0...100).contains(pct) else { return nil }\n let reset = t(resetIndex)\n return Window(\n pct: pct,\n reset: reset,\n elapsed: markerElapsed(reset: reset, elapsed: n(elapsedIndex)))\n }\n // Third bar = the per-model weekly window: a non-empty scoped model is the\n // presence signal. Its reset can legitimately be unavailable, so do not\n // mistake a missing reset for an absent scoped window and show Sonnet.\n let sonnetReset = t(6)\n var sonnet: Window? = nil\n var sonnetLabel = \"Sonnet only\"\n let scopedReset = t(12)\n let scopedModel = t(10)\n if !scopedModel.isEmpty {\n // A malformed scoped percentage is unavailable too, but must not make\n // us fall back to the unrelated legacy Sonnet window.\n if let p = n(11), (0...100).contains(p) {\n let reset = scopedReset.isEmpty ? \"—\" : scopedReset\n sonnet = Window(pct: p, reset: reset, elapsed: markerElapsed(reset: reset, elapsed: n(15)))\n sonnetLabel = scopedModel\n }\n } else if !sonnetReset.isEmpty, sonnetReset != \"—\", let p = n(5) {\n sonnet = Window(pct: p, reset: sonnetReset, elapsed: nil)\n }\n let spent = t(8)\n let limit = t(9)\n let extra: (pct: Int, spent: String, limit: String)? =\n (spent.isEmpty || limit.isEmpty) ? nil : n(7).map { (pct: $0, spent: spent, limit: limit) }\n // Dispatch the balance by the SELECTED vendor, not by vendor_short: Kimi and\n // Moonshot both report vendor_short = \"kmi\" in the Rust binary, so keying on\n // vendor_short would collide and read the wrong field.\n let balanceFieldIndex: Int?\n switch vendor {\n case \"openrouter\": balanceFieldIndex = 17\n case \"deepseek\": balanceFieldIndex = 18\n case \"kilo\": balanceFieldIndex = 19\n case \"novita\": balanceFieldIndex = 20\n case \"moonshot\": balanceFieldIndex = 21\n case \"grok\": balanceFieldIndex = 22\n case \"anthropic_api\": balanceFieldIndex = 23\n default: balanceFieldIndex = nil\n }\n let balance = balanceFieldIndex.flatMap { t($0).isEmpty ? nil : t($0) }\n // Vendors with no rate-limit windows show only a balance; suppress the fake\n // 5h/7d 0% rows their session_pct/weekly_pct aliases would otherwise paint.\n let balanceOnly = balanceFieldIndex != nil\n // Anthropic API exposes spend-vs-limit instead of a balance, and reports the\n // spend % through the session/weekly aliases. When a limit is configured it\n // becomes an extra ($) bar; otherwise it is balance-only headline display.\n // FORMAT tail: aapi_headline(23) aapi_pct(24) aapi_spent(25) aapi_limit(26).\n let aapiLimit = t(26)\n let aapiExtra: (pct: Int, spent: String, limit: String)?\n if vendor == \"anthropic_api\", !aapiLimit.isEmpty, aapiLimit != \"—\",\n let aapiPct = n(24), (0...100).contains(aapiPct), !t(25).isEmpty {\n aapiExtra = (pct: aapiPct, spent: t(25), limit: aapiLimit)\n } else {\n aapiExtra = nil\n }\n // With a limit configured the spend-vs-limit bar replaces the headline, so\n // avoid showing both the \"cr\" balance and the extra ($) row at once.\n let displayBalance = aapiExtra == nil ? balance : nil\n return Snapshot(plan: t(0),\n hasUsageWindows: !balanceOnly,\n creditBalance: displayBalance,\n session: quotaWindow(1, 2, 13),\n weekly: quotaWindow(3, 4, 14),\n sonnet: sonnet,\n sonnetLabel: sonnetLabel,\n extra: aapiExtra ?? extra)\n}\n\n// ─── Preferences UI (SwiftUI) ────────────────────────────────────────────\nextension Color {\n init(hexString: String) { self.init(nsColor: hexColor(hexString)) }\n var hexString: String {\n let ns = NSColor(self).usingColorSpace(.sRGB) ?? .black\n return String(format: \"#%02x%02x%02x\",\n Int((ns.redComponent * 255).rounded()),\n Int((ns.greenComponent * 255).rounded()),\n Int((ns.blueComponent * 255).rounded()))\n }\n}\n\nstruct HexColorPicker: View {\n let title: String\n @Binding var hex: String\n var body: some View {\n ColorPicker(title, selection: Binding(\n get: { Color(hexString: hex) },\n set: { hex = $0.hexString }\n ), supportsOpacity: false)\n }\n}\n\n// ─── Vendor login / config (mirrors the GNOME \"Vendors\" tab) ──────────────\nstruct VendorAuth {\n let id, name, kind, cli, login, pkg, env: String\n}\n\nlet VENDOR_AUTH: [VendorAuth] = [\n VendorAuth(id: \"anthropic\", name: \"Anthropic (Claude)\", kind: \"oauth\", cli: \"claude\", login: \"claude\", pkg: \"@anthropic-ai/claude-code\", env: \"\"),\n VendorAuth(id: \"openai\", name: \"OpenAI (Codex)\", kind: \"oauth\", cli: \"codex\", login: \"codex login\", pkg: \"@openai/codex\", env: \"\"),\n VendorAuth(id: \"zai\", name: \"Z.AI (GLM)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ZAI_API_KEY\"),\n VendorAuth(id: \"openrouter\", name: \"OpenRouter\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"OPENROUTER_API_KEY\"),\n VendorAuth(id: \"deepseek\", name: \"DeepSeek\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"DEEPSEEK_API_KEY\"),\n VendorAuth(id: \"kimi\", name: \"Kimi\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KIMI_API_KEY\"),\n VendorAuth(id: \"kilo\", name: \"Kilo\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"KILO_API_KEY\"),\n VendorAuth(id: \"novita\", name: \"Novita\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"NOVITA_API_KEY\"),\n VendorAuth(id: \"moonshot\", name: \"Moonshot\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"MOONSHOT_API_KEY\"),\n VendorAuth(id: \"grok\", name: \"Grok (xAI)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"XAI_MANAGEMENT_KEY\"),\n VendorAuth(id: \"anthropic_api\", name: \"Anthropic (API)\", kind: \"apikey\", cli: \"\", login: \"\", pkg: \"\", env: \"ANTHROPIC_ADMIN_KEY\"),\n]\n\n// The config file the Rust binary would actually read. On macOS\n// `directories::ProjectDirs` resolves to ~/Library/Application Support, so\n// checking only ~/.config reported \"no key configured\" for a key the binary\n// was happily using. Prefer the canonical location, fall back to the legacy\n// Unix path the docs have always shown (the binary accepts both).\nfunc configPathTOML() -> String {\n let appSupport = \"\\(NSHomeDirectory())/Library/Application Support/ai-usagebar/config.toml\"\n if FileManager.default.fileExists(atPath: appSupport) { return appSupport }\n return \"\\(NSHomeDirectory())/.config/ai-usagebar/config.toml\"\n}\n\nfunc configHasApiKeyTOML(_ section: String) -> Bool {\n guard let value = configValueTOML(section, \"api_key\") else { return false }\n return !value.isEmpty\n}\n\nfunc configEnabledTOML(_ section: String) -> Bool? {\n guard let value = configValueTOML(section, \"enabled\") else { return nil }\n switch value.lowercased() {\n case \"true\": return true\n case \"false\": return false\n default: return nil\n }\n}\n\n/// Read a single `key` under `[section]` from TOML text. Pure (no filesystem)\n/// so the enabled-flag and api_key_env parsing is testable. Handles quoted\n/// strings, bare booleans (`enabled = false`), inline comments, and `api_key_env`.\nfunc tomlValueInText(_ text: String, section: String, key: String) -> String? {\n var inSection = false\n for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n let line = String(raw).trimmingCharacters(in: .whitespaces)\n if line.hasPrefix(\"[\") {\n inSection = line == \"[\\(section)]\"\n continue\n }\n guard inSection, !line.hasPrefix(\"#\") else { continue }\n let parts = line.split(separator: \"=\", maxSplits: 1).map(String.init)\n guard parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces) == key else { continue }\n // A `#` starts a comment only outside quotes. Check for a quoted value\n // first so `api_key = \"sk-abc#def\"` keeps its embedded `#` instead of\n // being truncated into an unterminated string.\n let value = parts[1].trimmingCharacters(in: .whitespaces)\n if let quote = value.first, quote == \"\\\"\" || quote == \"'\" {\n let content = value.dropFirst()\n guard let end = content.firstIndex(of: quote) else { continue }\n return String(content[..<end])\n }\n // Unquoted value: strip a trailing inline comment — `enabled = false\n // # opt-in` is a bare boolean, not a string starting with '#'.\n var bare = value\n if let hash = bare.firstIndex(of: \"#\") {\n bare = String(bare[..<hash]).trimmingCharacters(in: .whitespaces)\n }\n // Bare tokens (booleans, numbers) reach here. Only `true`/`false` are\n // meaningful for the keys this reader serves; everything else is left\n // for the caller to ignore.\n return bare\n }\n return nil\n}\n\nfunc configValueTOML(_ section: String, _ key: String) -> String? {\n let path = configPathTOML()\n guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }\n return tomlValueInText(text, section: section, key: key)\n}\n\nfunc apiKeyEnvironment(_ v: VendorAuth) -> String {\n configValueTOML(v.id, \"api_key_env\") ?? v.env\n}\n\n/// Rust defaults (`src/config.rs`): the OAuth/api-key vendors that ship enabled,\n/// versus the opt-in balance vendors that default to disabled. An omitted\n/// `[vendor].enabled` must reproduce these, not silently enable everything.\nfunc defaultEnabled(_ id: String) -> Bool {\n switch id {\n case \"anthropic\", \"openai\", \"zai\", \"openrouter\": return true\n case \"deepseek\", \"kimi\", \"kilo\", \"novita\", \"moonshot\", \"grok\", \"anthropic_api\": return false\n default: return true\n }\n}\n\nfunc vendorEnabled(_ v: VendorAuth) -> Bool {\n if let explicit = configEnabledTOML(v.id) { return explicit }\n return defaultEnabled(v.id)\n}\n\nfunc keychainHasClaude() -> Bool {\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/security\")\n p.arguments = [\"find-generic-password\", \"-s\", \"Claude Code-credentials\"]\n p.standardOutput = FileHandle.nullDevice\n p.standardError = FileHandle.nullDevice\n do { try p.run(); p.waitUntilExit(); return p.terminationStatus == 0 } catch { return false }\n}\n\nfunc vendorConfigured(_ v: VendorAuth) -> Bool {\n guard vendorEnabled(v) else { return false }\n let home = NSHomeDirectory()\n let fm = FileManager.default\n if v.id == \"anthropic\" {\n return fm.fileExists(atPath: \"\\(home)/.claude/.credentials.json\") || keychainHasClaude()\n }\n if v.id == \"openai\" {\n return fm.fileExists(atPath: \"\\(home)/.codex/auth.json\")\n }\n if let e = ProcessInfo.processInfo.environment[apiKeyEnvironment(v)], !e.isEmpty { return true }\n return configHasApiKeyTOML(v.id)\n}\n\nfunc cliInstalled(_ cli: String) -> Bool {\n let home = NSHomeDirectory()\n let fm = FileManager.default\n for dir in [\"\\(home)/.local/bin\", \"/opt/homebrew/bin\", \"/usr/local/bin\", \"\\(home)/.cargo/bin\"]\n where fm.isExecutableFile(atPath: \"\\(dir)/\\(cli)\") {\n return true\n }\n // Fall back to a login shell (covers nvm etc.).\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/bin/bash\")\n p.arguments = [\"-lc\", \"command -v \\(cli)\"]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile()\n p.waitUntilExit()\n return !(String(data: data, encoding: .utf8) ?? \"\")\n .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n } catch { return false }\n}\n\n// Write a script to a temp file and run it in Terminal.app (no AppleScript quoting hell).\nfunc runInTerminal(_ script: String) {\n let tmp = NSTemporaryDirectory() + \"ai-usagebar-vendor.sh\"\n try? script.write(toFile: tmp, atomically: true, encoding: .utf8)\n try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmp)\n let osa = \"tell application \\\"Terminal\\\" to do script \\\"bash '\\(tmp)'; rm -f '\\(tmp)'\\\"\\n\" +\n \"tell application \\\"Terminal\\\" to activate\"\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", osa]\n try? p.run()\n}\n\nfunc oauthScript(_ v: VendorAuth) -> String {\n return \"\"\"\n export PATH=\"$HOME/.local/bin:$PATH\"\n if command -v \\(v.cli) >/dev/null 2>&1; then\n \\(v.login)\n else\n echo \"\\(v.cli) nao encontrado. Instalo em ~/.local sem sudo. Pacote: \\(v.pkg)\"\n read -p \"Instalar agora? [y/N] \" a\n if [ \"$a\" = y ] || [ \"$a\" = Y ]; then npm i -g --prefix \"$HOME/.local\" \\(v.pkg) && hash -r && \\(v.login); fi\n fi\n echo\n read -p \"Enter para fechar...\"\n \"\"\"\n}\n\nfunc openTuiInTerminal() {\n let cargo = \"\\(NSHomeDirectory())/.cargo/bin/ai-usagebar-tui\"\n let tui = FileManager.default.isExecutableFile(atPath: cargo) ? cargo : \"ai-usagebar-tui\"\n runInTerminal(\"\\\"\\(tui)\\\"\\necho\\nread -p \\\"Enter para fechar...\\\"\")\n}\n\nstruct VendorsSection: View {\n @State private var configured: [String: Bool] = [:]\n @State private var cliPresent: [String: Bool] = [:]\n @State private var checking = false\n\n var body: some View {\n GroupBox(\"Vendors\") {\n VStack(alignment: .leading, spacing: 8) {\n ForEach(VENDOR_AUTH, id: \\.id) { v in\n HStack(alignment: .firstTextBaseline) {\n VStack(alignment: .leading, spacing: 2) {\n Text(v.name)\n Text(statusText(v)).font(.caption).foregroundColor(.secondary)\n }\n Spacer()\n Button(buttonLabel(v)) { action(v) }\n }\n }\n if checking {\n Text(\"verificando…\").font(.caption).foregroundColor(.secondary)\n }\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .onAppear(perform: refresh)\n }\n\n private func refresh() {\n checking = true\n DispatchQueue.global(qos: .userInitiated).async {\n var conf: [String: Bool] = [:]\n var cli: [String: Bool] = [:]\n for v in VENDOR_AUTH {\n conf[v.id] = vendorConfigured(v)\n // OAuth vendors need their CLI to log in; apikey vendors are\n // configured via the TUI.\n if v.kind == \"oauth\" { cli[v.id] = cliInstalled(v.cli) }\n }\n DispatchQueue.main.async {\n self.configured = conf\n self.cliPresent = cli\n self.checking = false\n }\n }\n }\n\n private func statusText(_ v: VendorAuth) -> String {\n if configured[v.id] == true { return \"✓ Configurado\" }\n if v.kind == \"oauth\" {\n if cliPresent[v.id] == false { return \"⚠ \\(v.cli) não instalado\" }\n return \"⚠ Não logado — \\(v.login)\"\n }\n return \"⚠ Sem API key — \\(apiKeyEnvironment(v))\"\n }\n\n private func buttonLabel(_ v: VendorAuth) -> String {\n if v.kind == \"oauth\" {\n if configured[v.id] == true { return \"Re-logar\" }\n if cliPresent[v.id] == false { return \"Instalar + logar\" }\n return \"Logar\"\n }\n return \"Configurar (TUI)\"\n }\n\n private func action(_ v: VendorAuth) {\n if v.kind == \"oauth\" { runInTerminal(oauthScript(v)) }\n else { openTuiInTerminal() }\n DispatchQueue.main.asyncAfter(deadline: .now() + 4) { refresh() }\n }\n}\n\nstruct SettingsView: View {\n @AppStorage(\"vendor\") private var vendor = \"anthropic\"\n @AppStorage(\"interval\") private var interval = 30.0\n @AppStorage(\"barWidth\") private var barWidth = 8\n @AppStorage(\"showSession\") private var showSession = true\n @AppStorage(\"showWeekly\") private var showWeekly = true\n @AppStorage(\"showExtra\") private var showExtra = false\n @AppStorage(\"showPercent\") private var showPercent = true\n @AppStorage(\"showBars\") private var showBars = true\n @AppStorage(\"showMeta\") private var showMeta = true\n @AppStorage(\"barStyle\") private var barStyle = \"block\"\n @AppStorage(\"colorLow\") private var colorLow = \"#98c379\"\n @AppStorage(\"colorMid\") private var colorMid = \"#e5c07b\"\n @AppStorage(\"colorHigh\") private var colorHigh = \"#d19a66\"\n @AppStorage(\"colorCritical\") private var colorCritical = \"#e06c75\"\n @AppStorage(\"colorEmpty\") private var colorEmpty = \"#3e4451\"\n @AppStorage(\"binaryPath\") private var binaryPath = \"\"\n\n // Only enabled vendors appear in the selector: Rust treats opt-in vendors\n // (deepseek/kimi/kilo/novita/moonshot/grok/anthropic_api) as disabled when\n // their `[vendor].enabled` is omitted, and so must this picker.\n private var vendors: [String] {\n VENDOR_AUTH.filter { vendorEnabled($0) }.map { $0.id }\n }\n\n var body: some View {\n // A ScrollView (not a Form) so the pane reliably scrolls on every macOS\n // version: on short displays the window can't grow past the screen, and\n // a plain Form clipped its top rows with no way to reach them.\n ScrollView(.vertical) {\n VStack(alignment: .leading, spacing: 18) {\n GroupBox(\"Exibição\") {\n VStack(alignment: .leading, spacing: 8) {\n Toggle(\"Mostrar barra de 5h (sessão)\", isOn: $showSession)\n Toggle(\"Mostrar barra semanal\", isOn: $showWeekly)\n Toggle(\"Mostrar barra de uso extra ($)\", isOn: $showExtra)\n Toggle(\"Mostrar porcentagem/valor\", isOn: $showPercent)\n Toggle(\"Mostrar barras (off = só números)\", isOn: $showBars)\n Toggle(\"Mostrar referência da meta (linha de ritmo)\", isOn: $showMeta)\n Picker(\"Estilo do indicador\", selection: $barStyle) {\n Text(\"Barras (░█)\").tag(\"block\")\n Text(\"Anel (○)\").tag(\"ring\")\n }\n Stepper(\"Largura da barra: \\(barWidth)\", value: $barWidth, in: 4...20)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Cores\") {\n VStack(alignment: .leading, spacing: 8) {\n HexColorPicker(title: \"Baixo (<50%)\", hex: $colorLow)\n HexColorPicker(title: \"Médio (50–74%)\", hex: $colorMid)\n HexColorPicker(title: \"Alto (75–89%)\", hex: $colorHigh)\n HexColorPicker(title: \"Crítico (≥90%)\", hex: $colorCritical)\n HexColorPicker(title: \"Vazio (fundo da barra)\", hex: $colorEmpty)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n GroupBox(\"Dados\") {\n VStack(alignment: .leading, spacing: 8) {\n Picker(\"Vendor\", selection: $vendor) {\n ForEach(vendors, id: \\.self) { Text($0) }\n }\n Stepper(\"Intervalo: \\(Int(interval))s\", value: $interval, in: 5...3600, step: 5)\n TextField(\"Caminho do binário (vazio = auto)\", text: $binaryPath)\n }\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n VendorsSection()\n }\n .padding(20)\n .frame(maxWidth: .infinity, alignment: .leading)\n }\n .frame(width: 460)\n .frame(minHeight: 300, idealHeight: 560, maxHeight: .infinity)\n }\n}\n\n// ─── App ─────────────────────────────────────────────────────────────────\nclass AppDelegate: NSObject, NSApplicationDelegate {\n var statusItem: NSStatusItem!\n var timer: Timer?\n var prefsWindow: NSWindow?\n var appearanceObservation: NSKeyValueObservation?\n var lastSnapshot: Snapshot?\n var pendingRefresh: DispatchWorkItem?\n /// Bumped on every refresh attempt. A result whose generation is no longer\n /// current belongs to a superseded attempt — most often the previously\n /// selected vendor — and must not be rendered. Without this, the timer,\n /// the Preferences window and a vendor change could each start their own\n /// subprocess and whichever finished last won, regardless of what the user\n /// had actually selected. Main-thread only.\n var refreshGeneration: Int = 0\n /// At most one subprocess in flight; a request arriving while one runs is\n /// coalesced rather than stacked.\n var refreshInFlight = false\n var refreshQueued = false\n let headerItem = NSMenuItem()\n var rows: [String: NSMenuItem] = [:]\n // Rebuilt on every render so only configured vendors show, and the active\n // one is checked. Kept as a field so the menu owns it for its lifetime.\n let vendorSubmenu = NSMenu()\n let vendorSubmenuItem = NSMenuItem(title: \"Trocar vendor\", action: nil, keyEquivalent: \"\")\n\n func applicationDidFinishLaunching(_ notification: Notification) {\n statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n statusItem.button?.title = \"5h …\"\n buildMenu()\n rebuildVendorSubmenu()\n observeAppearanceChanges()\n refresh()\n restartTimer()\n NotificationCenter.default.addObserver(\n self, selector: #selector(settingsChanged),\n name: UserDefaults.didChangeNotification, object: nil)\n }\n\n func buildMenu() {\n let menu = NSMenu()\n menu.autoenablesItems = false\n\n menu.addItem(headerItem)\n for key in [\"session\", \"weekly\", \"sonnet\", \"extra\"] {\n let it = NSMenuItem()\n rows[key] = it\n menu.addItem(it)\n }\n\n menu.addItem(.separator())\n addAction(menu, \"Atualizar agora\", #selector(refreshAction), \"r\")\n addAction(menu, \"Abrir TUI\", #selector(openTui), \"t\")\n vendorSubmenuItem.submenu = vendorSubmenu\n menu.addItem(vendorSubmenuItem)\n addAction(menu, \"Preferências…\", #selector(openPrefs), \",\")\n menu.addItem(.separator())\n addAction(menu, \"Sair\", #selector(quit), \"q\")\n\n statusItem.menu = menu\n }\n\n func addAction(_ menu: NSMenu, _ title: String, _ sel: Selector, _ key: String) {\n let it = NSMenuItem(title: title, action: sel, keyEquivalent: key)\n it.target = self\n menu.addItem(it)\n }\n\n @objc func refreshAction() { refresh() }\n @objc func quit() { NSApp.terminate(nil) }\n\n @objc func openPrefs() {\n if prefsWindow == nil {\n let host = NSHostingController(rootView: SettingsView())\n // Install the host view directly so this window owns its size on\n // macOS 12 as well. The SwiftUI ScrollView still fills the\n // resizable content area without expanding it to its full height.\n let avail = NSScreen.main?.visibleFrame.height ?? 700\n let initialSize = NSSize(width: 460, height: min(560, avail - 40))\n let w = NSWindow(contentRect: NSRect(origin: .zero, size: initialSize),\n styleMask: [.titled, .closable, .resizable],\n backing: .buffered,\n defer: false)\n w.contentViewController = host\n w.title = \"AI Usage Bar — Preferências\"\n // Resizable so the content can always be reached; a min size keeps\n // it usable, and the initial height is clamped to the visible screen\n // so the top never lands under the menu bar on short displays.\n w.contentMinSize = NSSize(width: 460, height: 360)\n w.setContentSize(initialSize)\n w.isReleasedWhenClosed = false\n w.center()\n prefsWindow = w\n }\n NSApp.activate(ignoringOtherApps: true)\n prefsWindow?.makeKeyAndOrderFront(nil)\n }\n\n @objc func openTui() {\n guard let tui = resolveBinary(\"ai-usagebar-tui\") else { return }\n let p = Process()\n p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n p.arguments = [\"-e\", \"tell application \\\"Terminal\\\" to do script \\\"\\(tui)\\\"\"]\n try? p.run()\n }\n\n // Settings changed in Preferences: re-render instantly from cache, re-arm\n // the timer, and re-fetch (debounced) in case vendor/binary changed.\n @objc func settingsChanged() {\n if let s = lastSnapshot { renderPanel(s); renderMenu(s) }\n restartTimer()\n pendingRefresh?.cancel()\n let work = DispatchWorkItem { [weak self] in self?.refresh() }\n pendingRefresh = work\n DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work)\n }\n\n func restartTimer() {\n timer?.invalidate()\n timer = Timer.scheduledTimer(withTimeInterval: INTERVAL, repeats: true) { [weak self] _ in\n self?.refresh()\n }\n }\n\n func observeAppearanceChanges() {\n guard let button = statusItem.button else { return }\n appearanceObservation = button.observe(\\NSStatusBarButton.effectiveAppearance,\n options: [.new]) { [weak self] _, _ in\n DispatchQueue.main.async { self?.rerenderAppearance() }\n }\n }\n\n func rerenderAppearance() {\n guard let snapshot = lastSnapshot else { return }\n renderPanel(snapshot)\n }\n\n func refresh() {\n guard let bin = resolveBinary(\"ai-usagebar\") else {\n setError(\"ai-usagebar não encontrado (PATH / ~/.cargo/bin / homebrew)\")\n return\n }\n // Coalesce: one subprocess at a time, and remember that another was\n // asked for so a vendor change during a fetch is not simply dropped.\n if refreshInFlight {\n refreshQueued = true\n return\n }\n refreshInFlight = true\n refreshGeneration += 1\n let generation = refreshGeneration\n // Captured for THIS attempt: reading `VENDOR` again on completion would\n // label a late result with whatever is selected by then.\n let vendor = VENDOR\n\n DispatchQueue.global(qos: .utility).async { [weak self] in\n let p = Process()\n p.executableURL = URL(fileURLWithPath: bin)\n p.arguments = [\"--vendor\", vendor, \"--format\", FORMAT_WITH_SENTINEL]\n let pipe = Pipe()\n p.standardOutput = pipe\n p.standardError = FileHandle.nullDevice\n\n // The subprocess takes the cache lock and may refresh OAuth over\n // the network; without a bound it can hold this worker for a very\n // long time. Kill it and report instead of hanging silently.\n let watchdog = DispatchWorkItem { if p.isRunning { p.terminate() } }\n DispatchQueue.global(qos: .utility)\n .asyncAfter(deadline: .now() + REFRESH_TIMEOUT, execute: watchdog)\n\n var out = \"\"\n do {\n try p.run()\n let data = pipe.fileHandleForReading.readDataToEndOfFile() // read before wait\n p.waitUntilExit()\n out = String(data: data, encoding: .utf8) ?? \"\"\n } catch {\n watchdog.cancel()\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { $0.setError(\"falha ao executar ai-usagebar\") }\n }\n return\n }\n watchdog.cancel()\n let timedOut = out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n DispatchQueue.main.async {\n self?.finishRefresh(generation) { me in\n // Selection may have changed while this ran.\n guard vendor == VENDOR else { return }\n if timedOut {\n me.setError(\"ai-usagebar demorou demais (>\\(Int(REFRESH_TIMEOUT))s)\")\n } else {\n me.consume(out)\n }\n }\n }\n }\n }\n\n /// Applies `body` only when `generation` is still the current attempt, then\n /// releases the in-flight slot and runs any request that arrived meanwhile.\n private func finishRefresh(_ generation: Int, _ body: (AppDelegate) -> Void) {\n let current = generation == refreshGeneration\n if current {\n refreshInFlight = false\n body(self)\n }\n if current && refreshQueued {\n refreshQueued = false\n refresh()\n }\n }\n\n func consume(_ output: String) {\n guard let data = output.data(using: .utf8),\n let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n let text = obj[\"text\"] as? String else {\n setError(\"saída inválida\")\n return\n }\n guard let snap = parse(text, vendor: VENDOR) else {\n lastSnapshot = nil\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n statusItem.button?.attributedTitle = run(stripMarkup(text), menuBarTextColor(appearance)) // Loading… / ⚠\n return\n }\n lastSnapshot = snap\n renderPanel(snap)\n renderMenu(snap)\n }\n\n func renderPanel(_ s: Snapshot) {\n let title = NSMutableAttributedString()\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n let primaryTextColor = menuBarTextColor(appearance)\n let secondaryTextColor = menuBarTextColor(appearance, secondary: true)\n func seg(_ tag: String, _ pct: Int, _ value: String, _ elapsed: Int?) {\n if title.length > 0 { title.append(run(\" \", secondaryTextColor)) }\n title.append(run(\"\\(tag) \", secondaryTextColor))\n if SHOW_PERCENT { title.append(run(value + (SHOW_BARS ? \" \" : \"\"), colorForPct(pct))) }\n if SHOW_BARS { title.append(progressAttr(pct: pct, width: BAR_WIDTH, elapsed: elapsed, appearance: appearance)) }\n if !SHOW_PERCENT && !SHOW_BARS { title.append(run(value, colorForPct(pct))) }\n }\n if let creditBalance = s.creditBalance {\n title.append(run(\"cr \", secondaryTextColor))\n title.append(run(creditBalance, primaryTextColor))\n } else if s.hasUsageWindows && SHOW_SESSION, let session = s.session {\n seg(\"5h\", session.pct, \"\\(session.pct)%\", session.elapsed)\n }\n if s.creditBalance == nil && s.hasUsageWindows && SHOW_WEEKLY, let weekly = s.weekly {\n seg(\"7d\", weekly.pct, \"\\(weekly.pct)%\", weekly.elapsed)\n }\n if SHOW_EXTRA, let e = s.extra { seg(\"ex\", e.pct, e.spent, nil) } // $ budget → no meta\n statusItem.button?.attributedTitle = title.length > 0 ? title : run(\"ai\", secondaryTextColor)\n }\n\n func renderMenu(_ s: Snapshot) {\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(s.plan.isEmpty ? \"AI Usage\" : s.plan,\n .labelColor, NSFont.boldSystemFont(ofSize: 13))\n\n func row(_ key: String, _ name: String, _ pct: Int, _ value: String, _ reset: String?, _ elapsed: Int?) {\n guard let item = rows[key] else { return }\n item.isHidden = false\n let a = NSMutableAttributedString()\n let label = name.count < 12\n ? name.padding(toLength: 12, withPad: \" \", startingAt: 0)\n : name\n a.append(run(label, .labelColor))\n a.append(progressAttr(pct: pct, width: MENU_BAR_W, elapsed: elapsed, menu: true, appearance: appearance))\n a.append(run(\" \\(value)\", colorForPct(pct)))\n if let r = reset, !r.isEmpty { a.append(run(\" ↺ \\(r)\", .secondaryLabelColor)) }\n item.attributedTitle = a\n }\n if let creditBalance = s.creditBalance {\n rows[\"session\"]?.isHidden = false\n rows[\"session\"]?.attributedTitle = run(\"Credits \\(creditBalance)\", .labelColor)\n rows[\"weekly\"]?.isHidden = true\n rows[\"sonnet\"]?.isHidden = true\n } else {\n if s.hasUsageWindows, let session = s.session {\n row(\"session\", \"Session\", session.pct, \"\\(session.pct)%\", session.reset, session.elapsed)\n } else { rows[\"session\"]?.isHidden = true }\n if s.hasUsageWindows, let weekly = s.weekly {\n row(\"weekly\", \"Weekly\", weekly.pct, \"\\(weekly.pct)%\", weekly.reset, weekly.elapsed)\n } else { rows[\"weekly\"]?.isHidden = true }\n }\n if let sn = s.sonnet { row(\"sonnet\", s.sonnetLabel, sn.pct, \"\\(sn.pct)%\", sn.reset, sn.elapsed) }\n else { rows[\"sonnet\"]?.isHidden = true }\n if let e = s.extra { row(\"extra\", \"Extra usage\", e.pct, \"\\(e.spent) / \\(e.limit)\", nil, nil) }\n else { rows[\"extra\"]?.isHidden = true }\n rebuildVendorSubmenu()\n }\n\n // Vendor switch submenu: lists only configured vendors, with a checkmark on\n // the active one. Selecting one rewrites the `vendor` default and triggers a\n // refresh via the shared settings-change observer.\n func rebuildVendorSubmenu() {\n vendorSubmenu.removeAllItems()\n let active = VENDOR\n let configured = VENDOR_AUTH.filter { vendorEnabled($0) && ($0.id == active || vendorConfigured($0)) }\n if configured.isEmpty {\n let none = NSMenuItem(title: \"Nenhum configurado\", action: nil, keyEquivalent: \"\")\n none.isEnabled = false\n vendorSubmenu.addItem(none)\n vendorSubmenuItem.isHidden = false\n return\n }\n for v in configured {\n let it = NSMenuItem(title: v.name, action: #selector(switchVendor(_:)), keyEquivalent: \"\")\n it.target = self\n it.representedObject = v.id\n it.state = (v.id == active) ? .on : .off\n vendorSubmenu.addItem(it)\n }\n vendorSubmenuItem.isHidden = false\n }\n\n @objc func switchVendor(_ sender: NSMenuItem) {\n guard let id = sender.representedObject as? String else { return }\n DEF.set(id, forKey: \"vendor\")\n }\n\n func setError(_ msg: String) {\n lastSnapshot = nil\n statusItem.button?.attributedTitle = run(\"⚠ ai\", hexColor(COLOR_CRITICAL))\n let appearance = statusItem.button?.effectiveAppearance ?? NSApp.effectiveAppearance\n headerItem.attributedTitle = run(msg, menuBarTextColor(appearance))\n for (_, it) in rows { it.isHidden = true }\n }\n}\n\n#if !SWIFT_TEST_HARNESS\n@main\nstruct AppMain {\n static func main() {\n DEF.register(defaults: SETTINGS_DEFAULTS)\n let app = NSApplication.shared\n let delegate = AppDelegate()\n app.delegate = delegate\n app.setActivationPolicy(.accessory) // menu-bar agent, no Dock icon\n app.run()\n }\n}\n#endif\n"}}}}