1 line
2.8 MiB
Plaintext
1 line
2.8 MiB
Plaintext
{"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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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: \"<\", with: \"<\")\n .replacingOccurrences(of: \">\", with: \">\")\n .replacingOccurrences(of: \""\", with: \"\\\"\")\n .replacingOccurrences(of: \"'\", with: \"'\")\n .replacingOccurrences(of: \"&\", 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"}}}} |