diff --git a/CHANGELOG.md b/CHANGELOG.md index 7762800..bb806f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ Notable changes to TallyBar. Format follows ## [Unreleased] +### Changed +- Grok and Grok Bot are one tab. All Grok products share a single weekly limit, so + two tabs could show two different numbers for it. The Grok tab shows xAI's weekly + bar with the week's split by product under it (for example "Grok Bot 53% · Grok + Build 8%"), read from the same billing reply as the bar. Grok Bot's plan name, + on-demand spend and prepaid line join the tab when the Grok Bot app is signed in, + and its weekly bar stands in when the `grok` CLI isn't set up. A saved Grok Bot + tab setting keeps the Grok tab on. + ## [0.2.1] — 2026-11-05 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index cc349ee..e9da7b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ changed and why belongs in commit messages and [`CHANGELOG.md`](CHANGELOG.md), n ## What this is -TallyBar is a **KDE Plasma 6 widget (Plasmoid)** monitoring AI session usage (Codex/OpenAI, Gemini, Claude, Antigravity, Grok, Grok Bot). Two halves: +TallyBar is a **KDE Plasma 6 widget (Plasmoid)** monitoring AI session usage (Codex/OpenAI, Gemini, Claude, Antigravity, Grok; Grok Bot is part of the Grok tab). Two halves: - **QML frontend** (`io.github.dlansama.tallybar/contents/ui/`) — `main.qml` (state), `CompactRepresentation.qml`, `FullRepresentation.qml`, `CostPopout.qml`, `SettingsPopout.qml`, modular sections in `ui/components/` (`UsageLimitCard.qml`, `ProviderTabBar.qml`, `CostSection.qml`, `ActionFooter.qml`, etc.) and helpers in `ui/lib/` (`format.js`, `ui_helpers.js`). - **Python backend** (`io.github.dlansama.tallybar/contents/code/`) — one-shot CLI, prints JSON, exits. @@ -48,8 +48,8 @@ Runtime: stdlib-only (no third-party deps). `.venv` is for pytest only. Release: - `providers/antigravity.py` — local language-server + Cloud Code API; `choose_antigravity_result`, `apply_google_one_credits`. - `providers/codex.py` — `codex` CLI JSON-RPC + OpenAI cookie fallback. - `providers/claude.py` — Claude browser-API path. -- `providers/grok.py` — Grok Build weekly credit bar, read live from xAI's billing endpoint with the grok CLI's access token when the network is allowed, else from the local `unified.jsonl` log; token/cost history from the same log, no network. -- `providers/grok_bot.py` — Grok Bot weekly bar from Cursor's dashboard service, signed in through the Grok Bot desktop app; network only. +- `providers/grok.py` — the Grok tab: weekly credit bar (the one limit all Grok products share) read live from xAI's billing endpoint with the grok CLI's access token when the network is allowed, else from the local `unified.jsonl` log; token/cost history from the same log, no network; `fold_grok_bot` merges Grok Bot's reading into it. +- `providers/grok_bot.py` — Grok Bot reading (plan, on-demand, prepaid, and a weekly bar for users without the CLI) from Cursor's dashboard service, signed in through the Grok Bot desktop app; network only. - `providers/cost.py` — cost enrichment + Antigravity token-capture pipeline (`update_antigravity_token_ledger`). - `providers/__init__.py` — re-exports public names backend.py imports. - `parsers.py` — pure payload → limit-row functions. No I/O. @@ -162,7 +162,9 @@ Runtime: stdlib-only (no third-party deps). `.venv` is for pytest only. Release: - The CLI's `grok-build`/`grok-composer` models are subscription-proxied and absent from LiteLLM → priced at xAI's published coding-model API rate via the embedded `pricing_data` rows ("Cost (if pay-per-use)"). `grok-4.6` needs its own exact key or family matching resolves it to `grok-4`. → `tests/test_providers.py::test_run_grok_local_*`, `::test_grok_*`, `::test_grok_live_*`, `tests/test_backend.py::test_grok_asks_the_live_endpoint_*`, `::test_grok_reads_only_its_log_*`, `tests/test_accounting.py::test_local_grok_*`, `tests/test_parsers.py::test_parse_grok_billing_config_weekly_credits`. -**Grok Bot (`grokbot`) is a network provider, separate from Grok Build.** `providers/grok_bot.run_grok_bot` shares nothing with `run_grok_local`: no `~/.grok` logs, no Grok archive, no cost scan, no cost card. It is an `api_task`, so `--no-network` never schedules it (status `idle`), and it is carried forward like claude/gemini/codex. +**All Grok usage (Grok, Grok Build, Grok Bot) draws on ONE limit, so there is ONE Grok tab and no `grokbot` provider in the snapshot.** `build_snapshot` still runs `providers/grok_bot.run_grok_bot` (an `api_task`: `--no-network` never schedules it) as an internal reading, carries it forward like claude/gemini/codex off the previous snapshot's `grok.grokBotReading`, and `providers.grok.fold_grok_bot` merges it into the Grok provider. The pool bar is ALWAYS the billing endpoint's (or its log's) `creditUsagePercent` — Grok Bot's own percent differs by fractions of a point and never overrides it; the bar's split by product is `productUsage` (`parsers.grok_product_split`, shown as the Weekly row's `sublabel`, dropped when an ended period rolls to 0%). Grok Bot adds only the On-demand row, the prepaid line and a plan name Grok lacks; its failure never touches a Grok reading. With no Grok CLI reading it stands in for the bar, and a failure with a remedy (locked wallet, expired sign-in) is shown on the Grok tab. A saved `grokbot` tab setting keeps Grok enabled (`_enabled_providers`). → `tests/test_providers.py::test_fold_grok_bot_*`, `tests/test_backend.py::test_grok_bot_*`, `::test_public_config_keeps_grok_on_for_a_saved_grok_bot_tab`. + +The Grok Bot reading itself shares nothing with `run_grok_local`: no `~/.grok` logs, no Grok archive, no cost scan, no cost card. - **Bearer:** the account's `cursor-access-token` in `~/.config/Grok Bot/sand-secrets.json` (`active` account, else the only one; `cursor-accounts` is plain JSON). Values are `plaintext:v1:`, `scoped:v1:<64 hex>:`, or a base64 Chromium `v10`/`v11` blob, decrypted with `cookies.decrypt_chromium_value(family="chromium")` and the Chromium Safe Storage password from the provider's OWN `KWalletClient(background=…)`, opened only when a value is encrypted and closed straight after. Its `wallet-locked`/`wallet-state-unknown` is the provider's status, never relabelled through the gemini/claude cookie loop. Never refreshed; the refresh token and profile (email) are never read. No store or no signed-in account is `not-running`; a value that won't decrypt, or a 401, is `unauthorized`. - **Secrets never reach output:** the token, machine id, account scope and checksum are never interpolated into a message or diagnostic. Error messages are fixed strings plus the HTTP status and the validated Connect `code`, never a response body or `str(exc)`. `http_text` refuses redirects for any request carrying `Authorization` (`_RefuseRedirects`): urllib's default handler copies the bearer onto the redirect target, `http://` included. → `tests/test_http_helpers.py::test_refuse_redirects_*`. - **Weekly bar** = `GetSandUsageStatus.usagePercent`, already a percent (clamped 0–100, never ×100). Pooled team allowance or no finite percent → `ok` with no bar, never an invented 0%. **On-demand row** only when `hasNonZeroIncludedLimit` is true, both `spendLimitUsage` cent fields are numbers, and `0 < individualLimit < 2147483647` (int32 max is the app's "unlimited"). Label `On-demand` puts it in the existing extra-usage slot. diff --git a/Makefile b/Makefile index b04f43e..2f0666e 100644 --- a/Makefile +++ b/Makefile @@ -143,6 +143,8 @@ render-baselines: echo "qml6 not found — install qt6-declarative (qt6-tools)"; exit 1; } @mkdir -p tests/render-baselines @$(MAKE) --no-print-directory _render OUTDIR=tests/render-baselines SOURCE="today=$(SHOT_DAY)" + @# Grok is a baseline only: `make screenshots` renders live usage and publishes it. + @$(SHOT_ENV) qml6 $(SHOT) -- today=$(SHOT_DAY) provider=grok out=tests/render-baselines/widget-grok.png @tmp=$$(mktemp -d) && trap 'rm -rf "$$tmp"' EXIT && python3 $(DEMO) cursors "$$tmp" $(CURSOR_PX) && \ $(SHOT_ENV) qml6 $(SHOT) -- today=$(SHOT_DAY) component=CostPopout graphMode=day walk=hover \ cursors="$$tmp" out=tests/render-baselines/cost-day-hover.png diff --git a/README.md b/README.md index 16576cd..b8b8009 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,7 @@ as a month heat grid. Clicking any bar or day expands it into a per-model breakd | **Claude** | session window (not on Ultra), weekly window, per-model windows, plan | claude.ai, using your browser's existing session; plan read from disk | | **Gemini** | daily usage, Google One AI credit pool | gemini.google.com, using your browser's existing session | | **Antigravity** | Gemini window, Claude/GPT window, plan | the local Antigravity language server, plus Google's Cloud Code API | -| **Grok** | weekly window | xAI's billing service via the `grok` CLI's sign-in; else the CLI's log | -| **Grok Bot** | weekly window; on-demand spend when capped | the Grok Bot desktop app's sign-in, against Cursor's dashboard service | +| **Grok** | the one weekly limit all Grok products share, split by product; on-demand spend when capped | xAI's billing service via the `grok` CLI's sign-in; else the CLI's log; the Grok Bot desktop app's sign-in adds plan and spend | Cost is computed separately, from **local log files only** — the transcripts each CLI already writes to `~/.claude`, `~/.codex`, `~/.gemini`, `~/.grok` and @@ -53,7 +52,7 @@ Each request is priced on its own, so a model that costs more for long prompts (Claude Haiku 5.5 past 100K tokens, GPT-5.5 past 272K, Gemini 2.5 and 3.1 Pro past 200K, Grok 4.x from 200K) is billed at the higher rate only for the requests whose prompt crosses that size. -Grok Bot has no local cost history yet: its tab shows the weekly bar only. +Usage through Grok Bot has no local cost history yet: the Grok tab's cost card covers Grok Build only. Clicking the cost row opens a separate chart window with a 7-day bar chart, a month grid, a 24-hour histogram, and a per-model breakdown you can expand by @@ -112,18 +111,22 @@ Each provider connects itself: - **Codex** works as soon as its CLI has been used once — it is read from the local app-server. -- **Grok** works as soon as the `grok` CLI has been signed in and used once. The - bar is read live with the CLI's sign-in; when that has expired, or the network - is off, it comes from the CLI's own log files instead. +- **Grok** works as soon as the `grok` CLI has been signed in and used once. All + Grok products (Grok, Grok Build, Grok Bot) share one weekly limit, so there is one + Grok tab: the bar is read live with the CLI's sign-in, with the split by product + under it; when that has expired, or the network is off, it comes from the CLI's own + log files instead. - **Antigravity** is read from its language server, which runs while Antigravity (or `agy`) is open. Plan and quota keep working from a cached token afterwards. - **Claude** and **Gemini** use the session cookies already in your browser (Chrome, Chromium, Brave, Edge or Firefox). If you are signed in to claude.ai or gemini.google.com in one of those, there is nothing to configure; if you are not, the widget shows a **Sign in** button that opens the right page. -- **Grok Bot** uses the sign-in the Grok Bot desktop app keeps: open the app and - sign in once. That sign-in is encrypted with the Chromium Safe Storage key, so - reading it needs KWallet, like the browser cookies. +- **Grok Bot** adds its plan name and on-demand spend to the Grok tab through the + sign-in the Grok Bot desktop app keeps: open the app and sign in once. That + sign-in is encrypted with the Chromium Safe Storage key, so reading it needs + KWallet, like the browser cookies. Without the Grok CLI, its weekly bar stands in + for the Grok bar. On a machine with none of these installed, the widget still starts and shows one status line per provider (`No matching browser cookies`, `Grok Build data dir diff --git a/io.github.dlansama.tallybar/contents/code/backend.py b/io.github.dlansama.tallybar/contents/code/backend.py index 94e21a5..6e75a12 100755 --- a/io.github.dlansama.tallybar/contents/code/backend.py +++ b/io.github.dlansama.tallybar/contents/code/backend.py @@ -32,6 +32,7 @@ apply_google_one_credits, choose_antigravity_result, compute_local_cost_summaries, + fold_grok_bot, google_one_credit_fresh, load_claude_statusline, run_antigravity_local, @@ -96,7 +97,7 @@ "update_refresh_interval", ] -PROVIDER_ORDER = ("codex", "claude", "gemini", "antigravity", "grok", "grokbot") +PROVIDER_ORDER = ("codex", "claude", "gemini", "antigravity", "grok") # The provider keys of a config written before `providerCatalog` existed. A saved # `providers` list is the ENABLED set, so a key missing from it means "turned off" only # if the catalog the list was saved under knew that key (see _enabled_providers). @@ -222,6 +223,9 @@ def _enabled_providers(config: dict[str, Any]) -> list[str]: catalog = config.get("providerCatalog") if not isinstance(catalog, list): catalog = list(_LEGACY_PROVIDER_CATALOG) + # Grok Bot was its own tab before all Grok usage shared one limit; keeping it on keeps Grok on. + if "grokbot" in saved and "grok" not in saved: + saved = [*saved, "grok"] enabled = [p for p in PROVIDER_ORDER if p in saved or p not in catalog] return enabled or list(PROVIDER_ORDER) @@ -591,6 +595,16 @@ def carry_forward_partial_antigravity_lanes(provider: dict[str, Any], _TRANSIENT_PROVIDER_STATUSES = frozenset(("timeout", "api-error", "unauthorized", "error")) +def _cached_grok_bot_view(cached: Any) -> dict[str, Any]: + """``cached`` reshaped so ``carry_forward_provider_last_good`` finds Grok Bot's last reading, + which rides on the Grok provider as ``grokBotReading``.""" + providers = cached.get("providers") if isinstance(cached, dict) else None + grok = providers.get("grok") if isinstance(providers, dict) else None + reading = grok.get("grokBotReading") if isinstance(grok, dict) else None + return {"timestamp": (cached or {}).get("timestamp") if isinstance(cached, dict) else None, + "providers": {"grokbot": reading}} + + def carry_forward_provider_last_good(provider: dict[str, Any], cached: Any, key: str, *, max_stale_seconds: int = 900) -> dict[str, Any]: """Freeze the last-known-good reading over a TRANSIENT provider failure. @@ -823,9 +837,9 @@ async def build_snapshot(args: argparse.Namespace) -> dict[str, Any]: # log) + token/cost history from ~/.grok logs. run_grok_local fills limits; # apply_cost_summaries adds the card. "grok": {**default_provider("Grok", "local-grok-logs"), "accentColor": "#1d9bf0"}, - # Grok Bot: a separate product and meter from Grok Build — network-only (Cursor's - # dashboard service, signed in through the Grok Bot desktop app). No cost card. - "grokbot": {**default_provider("Grok Bot", "grok-bot"), "accentColor": "#e6e6e6"}, + # Grok Bot's own reading (Cursor's dashboard service, through the Grok Bot desktop + # app's sign-in). Folded into "grok" and dropped before the snapshot is emitted. + "grokbot": default_provider("Grok Bot", "grok-bot"), } diagnostics: dict[str, Any] = {} @@ -1129,9 +1143,15 @@ def get_task_result(task: asyncio.Task | None, fallback: dict[str, Any], label: # (Cloudflare challenge / one-off timeout) on Claude/Gemini/Codex/Grok Bot would otherwise # overwrite the cache with an error card. Freeze the last good reading for a short grace # window off the cached snapshot loaded up top; beyond it the real error resurfaces. - for _name in ("claude", "gemini", "codex", "grokbot"): + for _name in ("claude", "gemini", "codex"): providers[_name] = carry_forward_provider_last_good(providers[_name], cached_snapshot, _name) + # Grok Bot is carried the same way, off the reading the previous snapshot kept on the Grok + # provider, then folded into it: all Grok usage shares one limit, so there is one Grok tab. + grok_bot = carry_forward_provider_last_good( + providers.pop("grokbot"), _cached_grok_bot_view(cached_snapshot), "grokbot") + providers["grok"] = {**fold_grok_bot(providers["grok"], grok_bot), "grokBotReading": grok_bot} + # Claude Code statusLine fallback (integrations/claude_code/statusline_capture.py): when # the claude.ai cookie path produced no live limits — Cloudflare 403/429, signed out, # wallet locked, or --no-network — use the quota Claude Code itself handed the hook. diff --git a/io.github.dlansama.tallybar/contents/code/parsers.py b/io.github.dlansama.tallybar/contents/code/parsers.py index 2da7efb..b036946 100644 --- a/io.github.dlansama.tallybar/contents/code/parsers.py +++ b/io.github.dlansama.tallybar/contents/code/parsers.py @@ -1038,6 +1038,30 @@ def walk(node: Any, label: str = "Usage", depth: int = 0) -> None: return limits[:4] +_GROK_PRODUCT_NAMES = {"grokbot": "Grok Bot", "grokbuild": "Grok Build"} + + +def grok_product_split(config: Any) -> str: + """The week's split of Grok's one shared limit by product ("Grok Bot 53% · Grok Build 8%"): + the largest non-zero shares of ``productUsage``, each a percent of the whole limit. + Empty when the reply carries no usable list. → tests/test_parsers.py::test_grok_product_split""" + items = config.get("productUsage") if isinstance(config, dict) else None + if not isinstance(items, list): + return "" + shares: list[tuple[float, str]] = [] + for item in items: + if not isinstance(item, dict): + continue + pct = item.get("usagePercent") + raw = str(item.get("product") or "").strip() + if not raw or isinstance(pct, bool) or not isinstance(pct, (int, float)) or not 0 < pct <= 100: + continue + name = _GROK_PRODUCT_NAMES.get(raw.lower()) or re.sub(r"(?<=[a-z])(?=[A-Z])", " ", raw) + shares.append((float(pct), name)) + shares.sort(key=lambda s: -s[0]) + return " · ".join(f"{name} {int(pct + 0.5)}%" for pct, name in shares[:3]) # half-up, like the bar + + def parse_grok_billing_config(event: Any) -> dict[str, Any] | None: """Parse a Grok Build ``billing: fetched credits config`` unified-log event. @@ -1132,6 +1156,9 @@ def parse_grok_billing_config(event: Any) -> dict[str, Any] | None: } if reset_iso: limit["resetAt"] = reset_iso + split = grok_product_split(config) + if split: + limit["sublabel"] = split tier_raw = ctx.get("subscriptionTier") or ctx.get("subscription_tier") or config.get("subscriptionTier") tier = str(tier_raw).strip() if isinstance(tier_raw, str) and tier_raw.strip() else None diff --git a/io.github.dlansama.tallybar/contents/code/providers/__init__.py b/io.github.dlansama.tallybar/contents/code/providers/__init__.py index f3c37f0..4a5e432 100644 --- a/io.github.dlansama.tallybar/contents/code/providers/__init__.py +++ b/io.github.dlansama.tallybar/contents/code/providers/__init__.py @@ -3,7 +3,7 @@ from .claude import apply_claude_statusline_fallback, load_claude_statusline, run_claude_api from .codex import run_openai_cookie_api, run_codex_rpc from .antigravity import choose_antigravity_result, run_antigravity_remote, run_antigravity_local, apply_google_one_credits -from .grok import run_grok_local +from .grok import fold_grok_bot, run_grok_local from .grok_bot import run_grok_bot from .cost import compute_local_cost_summaries, apply_cost_summaries @@ -21,6 +21,7 @@ "run_antigravity_remote", "run_antigravity_local", "apply_google_one_credits", + "fold_grok_bot", "run_grok_local", "run_grok_bot", "compute_local_cost_summaries", diff --git a/io.github.dlansama.tallybar/contents/code/providers/grok.py b/io.github.dlansama.tallybar/contents/code/providers/grok.py index bea1e2c..5490dab 100644 --- a/io.github.dlansama.tallybar/contents/code/providers/grok.py +++ b/io.github.dlansama.tallybar/contents/code/providers/grok.py @@ -432,6 +432,7 @@ def _roll_ended_period(result: dict[str, Any], period: dict[str, Any], now: dt.d return limit = dict(result["limits"][0]) limit["percent"] = 0.0 + limit.pop("sublabel", None) # the ended week's split describes nothing current limit.pop("resetAt", None) limit["reset"] = "" rolled: dict[str, Any] = {k: v for k, v in period.items() if k not in ("start", "end")} @@ -442,3 +443,42 @@ def _roll_ended_period(result: dict[str, Any], period: dict[str, Any], now: dt.d result["limits"] = [limit] + list(result["limits"][1:]) result["period"] = rolled result["message"] = "Grok billing period ended — 0% until the next Grok session reports usage" + + +# Statuses of a Grok Bot reading that still say something the Grok tab's own "no CLI" state can't +# (a locked wallet to unlock, a sign-in to renew) when it is the only Grok source there is. +_GROK_BOT_REMEDIES = frozenset(("wallet-locked", "wallet-state-unknown", "unauthorized", "api-error", "timeout")) + + +def fold_grok_bot(grok: dict[str, Any], bot: Any) -> dict[str, Any]: + """The Grok tab with Grok Bot's reading folded in: all Grok products draw on ONE limit. + + The pool bar stays the billing endpoint's (``grok``). Grok Bot adds its on-demand row, + its prepaid line and a plan name when ``grok`` has none, and only ever as extra: a failed + Grok Bot read never touches a Grok reading. With no Grok CLI reading at all, Grok Bot's + own weekly bar stands in for it, and a failure that has a remedy (unlock KWallet, sign in + again) is shown. → tests/test_providers.py::test_fold_grok_bot_*""" + if not isinstance(bot, dict): + return grok + bot_ok = bot.get("status") == "ok" + bot_limits = [lim for lim in bot.get("limits") or [] if isinstance(lim, dict)] + out = dict(grok) + if grok.get("status") == "ok" and grok.get("limits"): + if not bot_ok: + return grok + out["limits"] = list(grok["limits"]) + [lim for lim in bot_limits if lim.get("label") != "Weekly"] + if bot.get("extraUsageText"): + out["extraUsageText"] = bot["extraUsageText"] + if not out.get("tier") and bot.get("tier"): + out["tier"] = bot["tier"] + return out + if bot_ok and bot_limits: + out.update(status="ok", message="Grok usage", source=bot.get("source") or out.get("source"), + limits=bot_limits, fetchedAt=bot.get("fetchedAt") or out.get("fetchedAt")) + for key in ("tier", "extraUsageText"): + if bot.get(key): + out[key] = bot[key] + return out + if grok.get("status") == "not-running" and bot.get("status") in _GROK_BOT_REMEDIES: + out.update(status=bot["status"], message=bot.get("message") or out.get("message")) + return out diff --git a/io.github.dlansama.tallybar/contents/code/providers/grok_bot.py b/io.github.dlansama.tallybar/contents/code/providers/grok_bot.py index 5314e6a..d89ce4b 100644 --- a/io.github.dlansama.tallybar/contents/code/providers/grok_bot.py +++ b/io.github.dlansama.tallybar/contents/code/providers/grok_bot.py @@ -1,8 +1,8 @@ """Grok Bot weekly usage, read through the Grok Bot desktop app's own sign-in. -Grok Bot (xAI's ``sand`` Electron app) meters a weekly allowance through Cursor's -dashboard service. This is a different product and a different meter from Grok Build -(``providers/grok.py``, the CLI's local ``~/.grok`` logs) — the two never share a tab. +Grok Bot (xAI's ``sand`` Electron app) reports its weekly allowance, plan and spend +through Cursor's dashboard service. All Grok products share one limit, so this reading is +folded into the Grok tab (``providers.grok.fold_grok_bot``) rather than shown on its own. The bearer token lives in the app's secret store, ``~/.config/Grok Bot/sand-secrets.json`` (layout from ``sand`` 0.63.0, ``dist/electron-main/main-core.cjs``): a JSON object of diff --git a/io.github.dlansama.tallybar/contents/ui/FullRepresentation.qml b/io.github.dlansama.tallybar/contents/ui/FullRepresentation.qml index e11dc2d..755043f 100644 --- a/io.github.dlansama.tallybar/contents/ui/FullRepresentation.qml +++ b/io.github.dlansama.tallybar/contents/ui/FullRepresentation.qml @@ -36,7 +36,7 @@ Item { if (root.configError.length > 0) root.settingsDrawerOpen = true; } - property var tabs: ["codex", "claude", "gemini", "antigravity", "grok", "grokbot"] + property var tabs: ["codex", "claude", "gemini", "antigravity", "grok"] property var screenGeometry: null property bool hostExpanded: true property bool costDrawerOpen: false @@ -427,8 +427,7 @@ Item { switch (providerKey) { case "codex": return i18n("Codex isn't running — start the Codex app to read usage."); case "antigravity": return i18n("Antigravity isn't running — open it to read live session usage."); - case "grok": return i18n("No recent Grok session — start one to see usage."); - case "grokbot": return i18n("Not signed in to Grok Bot — open Grok Bot and sign in to see its weekly usage."); + case "grok": return i18n("No Grok usage found — use the grok CLI or sign in to the Grok Bot app to see the shared weekly limit."); default: return i18n("%1 isn't running.", root.tabLabel(providerKey)); } } @@ -507,10 +506,10 @@ Item { // fires when there's no concrete problem status to show. if (root.loading && (status === "" || status === "ok")) return i18n("Fetching usage data…"); - // Grok Bot's wallet need is its own sign-in, not browser cookies. - if (status === "wallet-locked" && root.selectedProvider === "grokbot") + // Grok's wallet need is the Grok Bot sign-in, not browser cookies. + if (status === "wallet-locked" && root.selectedProvider === "grok") return i18n("KWallet is locked. Unlock it to read your Grok Bot sign-in."); - if (status === "wallet-state-unknown" && root.selectedProvider === "grokbot") + if (status === "wallet-state-unknown" && root.selectedProvider === "grok") return i18n("KWallet state couldn't be checked. Unlock it and refresh to read your Grok Bot sign-in."); if (status === "wallet-locked") return i18n("KWallet is locked. Unlock it to read this provider's session cookies."); @@ -856,7 +855,7 @@ Item { }); } - readonly property var allProviderKeys: ["codex", "claude", "gemini", "antigravity", "grok", "grokbot"] + readonly property var allProviderKeys: ["codex", "claude", "gemini", "antigravity", "grok"] function providersValue() { const p = root.configValue("providers", root.allProviderKeys); return Array.isArray(p) && p.length > 0 ? p : root.allProviderKeys; diff --git a/io.github.dlansama.tallybar/contents/ui/SettingsPopout.qml b/io.github.dlansama.tallybar/contents/ui/SettingsPopout.qml index 23d74b1..df68ce5 100644 --- a/io.github.dlansama.tallybar/contents/ui/SettingsPopout.qml +++ b/io.github.dlansama.tallybar/contents/ui/SettingsPopout.qml @@ -262,8 +262,8 @@ PlasmaCore.PopupPlasmaWindow { property bool act: root.providerEnabled(modelData) - // Share the row with the Row's real gaps (spacing 6 × n-1), so a - // sixth provider doesn't push the last chip past the card edge. + // Share the row with the Row's real gaps (spacing 6 × n-1), so an + // added provider doesn't push the last chip past the card edge. width: (root.settingsWidth - 28 - 6 * (root.allProviderKeys.length - 1)) / root.allProviderKeys.length height: 28 radius: 7 @@ -317,8 +317,8 @@ PlasmaCore.PopupPlasmaWindow { // Muted reads as the "active" (highlighted) chip here. property bool act: root.providerMuted(modelData) - // Share the row with the Row's real gaps (spacing 6 × n-1), so a - // sixth provider doesn't push the last chip past the card edge. + // Share the row with the Row's real gaps (spacing 6 × n-1), so an + // added provider doesn't push the last chip past the card edge. width: (root.settingsWidth - 28 - 6 * (root.allProviderKeys.length - 1)) / root.allProviderKeys.length height: 28 radius: 7 diff --git a/io.github.dlansama.tallybar/contents/ui/lib/format.js b/io.github.dlansama.tallybar/contents/ui/lib/format.js index 699ad20..9bfb9d2 100644 --- a/io.github.dlansama.tallybar/contents/ui/lib/format.js +++ b/io.github.dlansama.tallybar/contents/ui/lib/format.js @@ -72,7 +72,6 @@ function providerAccent(providerKey) { if (providerKey === "gemini") return "#2f74f5"; if (providerKey === "antigravity") return "#30b795"; if (providerKey === "grok") return "#1d9bf0"; - if (providerKey === "grokbot") return "#e6e6e6"; return "#218df4"; } diff --git a/io.github.dlansama.tallybar/contents/ui/lib/ui_helpers.js b/io.github.dlansama.tallybar/contents/ui/lib/ui_helpers.js index 213b8dc..5810f1d 100644 --- a/io.github.dlansama.tallybar/contents/ui/lib/ui_helpers.js +++ b/io.github.dlansama.tallybar/contents/ui/lib/ui_helpers.js @@ -8,7 +8,6 @@ function tabLabel(provider) { if (provider === "codex") return "Codex"; if (provider === "claude") return "Claude"; if (provider === "grok") return "Grok"; - if (provider === "grokbot") return "Grok Bot"; return "Codex"; } @@ -18,9 +17,6 @@ function providerShortLabel(key) { if (key === "gemini") return "Gemini"; if (key === "antigravity") return "Antigr."; if (key === "grok") return "Grok"; - // "Bot", not "Grok Bot": the settings chips share a fixed width six ways, and the - // neighbouring chip already reads "Grok". - if (key === "grokbot") return "Bot"; return key; } @@ -54,8 +50,6 @@ function dashboardUrl(providerKey) { return "https://claude.ai/settings/usage"; if (providerKey === "grok") return "https://grok.com/?_s=usage"; - if (providerKey === "grokbot") - return "https://cursor.com/dashboard?tab=billing"; return "https://chatgpt.com/codex/settings/usage"; } @@ -66,7 +60,7 @@ function statusUrl(providerKey) { return "https://status.openai.com/"; if (providerKey === "claude") return "https://status.claude.com/"; - if (providerKey === "grok" || providerKey === "grokbot") + if (providerKey === "grok") return "https://status.x.ai/"; return "https://status.openai.com/"; } diff --git a/io.github.dlansama.tallybar/contents/ui/main.qml b/io.github.dlansama.tallybar/contents/ui/main.qml index 695fc8b..de15d84 100644 --- a/io.github.dlansama.tallybar/contents/ui/main.qml +++ b/io.github.dlansama.tallybar/contents/ui/main.qml @@ -45,7 +45,7 @@ PlasmoidItem { // --set-config call returns, well before a slower concurrent refresh's backend process exits. property int configWriteSeq: 0 property int configSeqAtRefreshStart: 0 - property var providerOrder: ["codex", "claude", "gemini", "antigravity", "grok", "grokbot"] + property var providerOrder: ["codex", "claude", "gemini", "antigravity", "grok"] property var activeProviders: activeProviderTabs() property var telemetry: ({ "ok": false, @@ -80,12 +80,6 @@ PlasmoidItem { "status": "loading", "source": "local-grok-logs", "limits": [] - }, - "grokbot": { - "label": "Grok Bot", - "status": "loading", - "source": "grok-bot", - "limits": [] } } }) diff --git a/po/plasma_applet_io.github.dlansama.tallybar.pot b/po/plasma_applet_io.github.dlansama.tallybar.pot index f523fff..cebfe2f 100644 --- a/po/plasma_applet_io.github.dlansama.tallybar.pot +++ b/po/plasma_applet_io.github.dlansama.tallybar.pot @@ -179,13 +179,9 @@ msgid "Antigravity isn't running — open it to read live session usage." msgstr "" #: io.github.dlansama.tallybar/contents/ui/FullRepresentation.qml:430 -msgid "No recent Grok session — start one to see usage." -msgstr "" - -#: io.github.dlansama.tallybar/contents/ui/FullRepresentation.qml:431 msgid "" -"Not signed in to Grok Bot — open Grok Bot and sign in to see its weekly " -"usage." +"No Grok usage found — use the grok CLI or sign in to the Grok Bot app to see " +"the shared weekly limit." msgstr "" #: io.github.dlansama.tallybar/contents/ui/FullRepresentation.qml:432 diff --git a/tests/render-baselines/widget-antigravity.png b/tests/render-baselines/widget-antigravity.png index d7904f1..fe21c9e 100644 Binary files a/tests/render-baselines/widget-antigravity.png and b/tests/render-baselines/widget-antigravity.png differ diff --git a/tests/render-baselines/widget-claude.png b/tests/render-baselines/widget-claude.png index 2ff41ed..b2c8fb4 100644 Binary files a/tests/render-baselines/widget-claude.png and b/tests/render-baselines/widget-claude.png differ diff --git a/tests/render-baselines/widget-grok.png b/tests/render-baselines/widget-grok.png new file mode 100644 index 0000000..b950e9e Binary files /dev/null and b/tests/render-baselines/widget-grok.png differ diff --git a/tests/test_backend.py b/tests/test_backend.py index 2e235ad..d3665d0 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -1491,30 +1491,31 @@ async def test_grok_bot_is_not_scheduled_with_no_network(): res, grok_bot, scheduled = await _grokbot_snapshot(no_network=True) grok_bot.assert_not_called() assert "_run_grok_bot" not in scheduled - gb = res["providers"]["grokbot"] - assert gb["label"] == "Grok Bot" and gb["status"] == "idle" - assert "network" in gb["message"].lower() - assert gb["accentColor"] == "#e6e6e6" + assert "grokbot" not in res["providers"] @pytest.mark.asyncio -async def test_grok_bot_runs_with_the_background_flag(): +async def test_grok_bot_runs_with_the_background_flag_and_stands_in_for_a_missing_cli_bar(): live = {"label": "Grok Bot", "status": "ok", "source": "grok-bot", "limits": [{"label": "Weekly", "percent": 42.0, "windowMinutes": 10080}]} res, grok_bot, scheduled = await _grokbot_snapshot(no_network=False, grok_bot_result=live) grok_bot.assert_called_once_with(1.0, background=True) - assert res["providers"]["grokbot"]["limits"][0]["percent"] == 42.0 + assert "grokbot" not in res["providers"] + grok = res["providers"]["grok"] + assert grok["status"] == "ok" and grok["limits"][0]["percent"] == 42.0 + assert grok["grokBotReading"]["limits"][0]["percent"] == 42.0 @pytest.mark.asyncio -async def test_grok_bot_transient_failure_carries_the_last_good_bar(): +async def test_grok_bot_transient_failure_carries_the_last_good_reading(): good = [{"label": "Weekly", "percent": 30.0}] cached = _cached_provider("Grok Bot", good, ts_offset=60.0, key="grokbot") + cached["providers"] = {"grok": {"label": "Grok", "grokBotReading": cached["providers"]["grokbot"]}} failing = {"label": "Grok Bot", "status": "api-error", "limits": [], "message": "HTTP 503"} res, _, _ = await _grokbot_snapshot(no_network=False, grok_bot_result=failing, cached=cached) - gb = res["providers"]["grokbot"] - assert gb["status"] == "ok" and gb["stale"] is True - assert gb["limits"][0]["percent"] == 30.0 + grok = res["providers"]["grok"] + assert grok["status"] == "ok" and grok["limits"][0]["percent"] == 30.0 + assert grok["grokBotReading"]["stale"] is True async def _grok_snapshot(no_network): @@ -1565,34 +1566,57 @@ async def test_grok_reads_only_its_log_with_no_network(): def test_public_config_without_a_saved_list_enables_every_provider(): assert backend.public_config({})["providers"] == list(backend.PROVIDER_ORDER) - assert "grokbot" in backend.PROVIDER_ORDER + assert "grokbot" not in backend.PROVIDER_ORDER -def test_public_config_pre_catalog_list_gains_grok_bot_but_keeps_removals(): - # Saved before Grok Bot existed, with Grok switched off by the user. +def test_public_config_pre_catalog_list_gains_a_provider_added_since_but_keeps_removals(monkeypatch): + monkeypatch.setattr(backend, "PROVIDER_ORDER", (*backend.PROVIDER_ORDER, "newprov")) + # Saved before the catalog existed, with Grok switched off by the user. old = {"providers": ["codex", "claude", "gemini", "antigravity"]} - assert backend.public_config(old)["providers"] == ["codex", "claude", "gemini", "antigravity", "grokbot"] + assert backend.public_config(old)["providers"] == ["codex", "claude", "gemini", "antigravity", "newprov"] -def test_public_config_respects_grok_bot_turned_off_after_the_catalog_knew_it(): +def test_public_config_respects_a_provider_turned_off_after_the_catalog_knew_it(): cfg = {"providers": ["codex", "claude"], "providerCatalog": list(backend.PROVIDER_ORDER)} assert backend.public_config(cfg)["providers"] == ["codex", "claude"] +def test_public_config_keeps_grok_on_for_a_saved_grok_bot_tab(): + """Grok Bot was its own tab; leaving it on leaves the merged Grok tab on, and turning + both off leaves Grok off.""" + old_catalog = [*backend.PROVIDER_ORDER, "grokbot"] + on = {"providers": ["codex", "grokbot"], "providerCatalog": old_catalog} + assert backend.public_config(on)["providers"] == ["codex", "grok"] + off = {"providers": ["codex"], "providerCatalog": old_catalog} + assert backend.public_config(off)["providers"] == ["codex"] + + def test_update_config_values_settles_the_old_list_before_stamping_the_catalog(tmp_path, monkeypatch): + monkeypatch.setattr(backend, "PROVIDER_ORDER", (*backend.PROVIDER_ORDER, "newprov")) monkeypatch.setattr(backend, "CONFIG_PATH", tmp_path / ".tallybar" / "config.json") backend.CONFIG_PATH.parent.mkdir(parents=True) backend.CONFIG_PATH.write_text(json.dumps({"providers": ["codex", "claude", "gemini", "antigravity"]})) - # An unrelated save must not turn Grok Bot (absent from the old list) into "off". + # An unrelated save must not turn a provider added since (absent from the old list) into "off". out = backend.update_config_values({"notificationsEnabled": False}) assert out["providerCatalog"] == list(backend.PROVIDER_ORDER) - assert out["providers"] == ["codex", "claude", "gemini", "antigravity", "grokbot"] - # Now the user turns Grok Bot off: it stays off. + assert out["providers"] == ["codex", "claude", "gemini", "antigravity", "newprov"] + # Now the user turns it off: it stays off. out = backend.update_config_values({"providers": ["codex", "claude", "gemini", "antigravity"]}) assert backend.public_config(out)["providers"] == ["codex", "claude", "gemini", "antigravity"] assert backend.public_config(backend.load_config())["providers"] == ["codex", "claude", "gemini", "antigravity"] +def test_update_config_values_rewrites_a_saved_grok_bot_tab_as_grok(tmp_path, monkeypatch): + monkeypatch.setattr(backend, "CONFIG_PATH", tmp_path / ".tallybar" / "config.json") + backend.CONFIG_PATH.parent.mkdir(parents=True) + backend.CONFIG_PATH.write_text(json.dumps({ + "providers": ["codex", "grokbot"], + "providerCatalog": [*backend.PROVIDER_ORDER, "grokbot"], + })) + out = backend.update_config_values({"notificationsEnabled": False}) + assert out["providers"] == ["codex", "grok"] + + @pytest.mark.asyncio async def test_pre_upgrade_price_cache_is_refetched_at_once(): """A pricing cache saved before prompt tiers (no "version") is refused by get_pricing, diff --git a/tests/test_parsers.py b/tests/test_parsers.py index 0b0aa10..3c57a4f 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -696,6 +696,36 @@ def test_extract_limits_from_json_depth_capped(): # parse_grok_billing_config (main-tree unique) # --------------------------------------------------------------------------- +def test_grok_product_split(): + cfg = {"productUsage": [ + {"product": "GrokBuild", "usagePercent": 8.0}, + {"product": "GrokBot", "usagePercent": 53.0}, + {"product": "SomeNewThing", "usagePercent": 0.0}, # zero shares are left out + {"product": "OtherThing", "usagePercent": "9"}, # not a number + {"product": "", "usagePercent": 5.0}, # no name + ]} + assert parsers.grok_product_split(cfg) == "Grok Bot 53% · Grok Build 8%" + assert parsers.grok_product_split({"productUsage": [{"product": "GrokCodeAgent", "usagePercent": 4.4}]}) \ + == "Grok Code Agent 4%" + assert parsers.grok_product_split({"productUsage": [{"product": f"P{i}", "usagePercent": 10 + i} + for i in range(5)]}).count("%") == 3 + for bad in ({}, {"productUsage": None}, {"productUsage": "x"}, {"productUsage": []}, None): + assert parsers.grok_product_split(bad) == "" + + +def test_parse_grok_billing_config_puts_the_split_under_the_weekly_bar(): + config = { + "creditUsagePercent": 53.0, + "currentPeriod": {"type": "USAGE_PERIOD_TYPE_WEEKLY", "start": "2026-11-03T11:37:05+00:00", + "end": "2030-01-01T00:00:00+00:00"}, + "productUsage": [{"product": "GrokBot", "usagePercent": 53.0}], + } + limit = parsers.parse_grok_billing_config({"ctx": {"config": config}})["limits"][0] + assert limit["percent"] == 53.0 and limit["sublabel"] == "Grok Bot 53%" + del config["productUsage"] + assert "sublabel" not in parsers.parse_grok_billing_config({"ctx": {"config": config}})["limits"][0] + + def test_parse_grok_billing_config_weekly_credits(): event = { "msg": "billing: fetched credits config", diff --git a/tests/test_providers.py b/tests/test_providers.py index 19ce35a..e5ad90a 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -1618,6 +1618,30 @@ def test_grok_live_reading_replaces_the_log_reading(tmp_path): assert calls[0]["headers"]["Accept"] == "application/json" +def test_grok_live_reading_carries_the_product_split(tmp_path): + from providers import grok as grok_mod + home = _grok_live_home(tmp_path) + http, _ = _fake_http((200, _LIVE_BODY)) + res = grok_mod.run_grok_local(timeout=1.0, grok_home=home, network=True, http=http) + assert res["limits"][0]["sublabel"] == "Grok Build 8%" + + +def test_grok_ended_period_drops_the_product_split(tmp_path): + from providers import grok as grok_mod + home = _grok_live_home(tmp_path) + log = home / "logs" / "unified.jsonl" + event = json.loads(log.read_text().splitlines()[0]) + event["ctx"]["config"]["productUsage"] = [{"product": "GrokBot", "usagePercent": 42.5}] + log.write_text(json.dumps(event) + "\n") + during = dt.datetime(2030, 7, 8, tzinfo=dt.timezone.utc) + after = dt.datetime(2030, 7, 12, tzinfo=dt.timezone.utc) # the reading's week has ended + res = grok_mod.run_grok_local(timeout=1.0, grok_home=home, now=during) + assert res["limits"][0]["percent"] == 42.5 and res["limits"][0]["sublabel"] == "Grok Bot 43%" + res = grok_mod.run_grok_local(timeout=1.0, grok_home=home, now=after) + assert res["limits"][0]["percent"] == 0.0 + assert "sublabel" not in res["limits"][0] + + def test_grok_live_reading_is_off_without_network(tmp_path): from providers import grok as grok_mod home = _grok_live_home(tmp_path) @@ -2432,3 +2456,63 @@ def test_grok_bot_prepaid_text_feeds_the_extra_usage_line(): p = {"limits": [{"label": "Weekly", "percent": 10}], "extraUsageText": "Prepaid usage: $1.00 this month"} enrich_ui_formatting({"grokbot": p}) assert p["formattedExtraUsageDetail"] == "Prepaid usage: $1.00 this month" + + +# --- Grok Bot folded into the one Grok tab ------------------------------------------ + +def _fold_grok(**kw): + base = {"label": "Grok", "status": "ok", "message": "Grok Build usage", "source": "grok-billing-api", + "fetchedAt": "2026-11-07T12:00:00+00:00", + "limits": [{"label": "Weekly", "percent": 53.0, "unit": "percent", "sublabel": "Grok Bot 53%"}]} + return {**base, **kw} + + +def _fold_bot(**kw): + base = {"label": "Grok Bot", "status": "ok", "source": "grok-bot", "tier": "SuperGrok week", + "fetchedAt": "2026-11-07T12:01:00+00:00", "extraUsageText": "Prepaid $12.00 spent this month", + "limits": [{"label": "Weekly", "percent": 53.76, "unit": "percent"}, + {"label": "On-demand", "percent": 10.0, "unit": "usd", "used": 5.0, "limit": 50.0}]} + return {**base, **kw} + + +def test_fold_grok_bot_keeps_the_pool_bar_and_adds_the_extras(): + from providers.grok import fold_grok_bot + out = fold_grok_bot(_fold_grok(), _fold_bot()) + assert [lim["label"] for lim in out["limits"]] == ["Weekly", "On-demand"] + assert out["limits"][0]["percent"] == 53.0 # not Grok Bot's 53.76 + assert out["source"] == "grok-billing-api" + assert out["extraUsageText"] == "Prepaid $12.00 spent this month" + assert out["tier"] == "SuperGrok week" # only because Grok had none + assert fold_grok_bot(_fold_grok(tier="SuperGrok Plus"), _fold_bot())["tier"] == "SuperGrok Plus" + + +def test_fold_grok_bot_failure_never_touches_a_grok_reading(): + from providers.grok import fold_grok_bot + grok = _fold_grok() + for status in ("wallet-locked", "unauthorized", "api-error", "timeout", "not-running", "idle"): + assert fold_grok_bot(grok, {"status": status, "message": "x", "limits": []}) == grok + assert fold_grok_bot(grok, None) == grok + + +def test_fold_grok_bot_weekly_bar_stands_in_when_there_is_no_cli_reading(): + from providers.grok import fold_grok_bot + cli_less = {"label": "Grok", "status": "not-running", "message": "Grok Build data dir (~/.grok) not found", + "limits": []} + out = fold_grok_bot(cli_less, _fold_bot()) + assert out["status"] == "ok" and out["label"] == "Grok" + assert [lim["percent"] for lim in out["limits"]] == [53.76, 10.0] + assert out["tier"] == "SuperGrok week" and out["source"] == "grok-bot" + + +def test_fold_grok_bot_shows_a_failure_with_a_remedy_only_when_it_is_the_sole_source(): + from providers.grok import fold_grok_bot + cli_less = {"label": "Grok", "status": "not-running", "message": "no ~/.grok", "limits": []} + locked = {"status": "wallet-locked", "message": "KWallet is locked — unlock it to read the Grok Bot sign-in", + "limits": []} + out = fold_grok_bot(cli_less, locked) + assert out["status"] == "wallet-locked" and "KWallet" in out["message"] + # Grok Bot simply not signed in is not a failure worth a banner on the Grok tab. + assert fold_grok_bot(cli_less, {"status": "not-running", "message": "x", "limits": []}) == cli_less + # A CLI that reports "api-empty" (installed, no usage yet) is not "not-running": left alone. + quiet = {"label": "Grok", "status": "api-empty", "message": "No Grok Build usage data yet", "limits": []} + assert fold_grok_bot(quiet, locked) == quiet diff --git a/tests/test_qml_render.py b/tests/test_qml_render.py index 6e92ce9..2334456 100644 --- a/tests/test_qml_render.py +++ b/tests/test_qml_render.py @@ -72,7 +72,7 @@ def test_delegate_scanner_catches_the_original_bug(): assert hit(bad) and not hit(good) -@pytest.mark.parametrize("provider", ["claude", "antigravity"]) +@pytest.mark.parametrize("provider", ["claude", "antigravity", "grok"]) def test_offscreen_render_matches_committed_screenshot(provider, tmp_path): # Local-only (needs qml6 + the QtQuick modules). The committed baselines ARE the # expected render of the mock fixture, so a UI change that alters them must be a diff --git a/tests/test_ui_helpers.py b/tests/test_ui_helpers.py index 028498c..cade3cb 100644 --- a/tests/test_ui_helpers.py +++ b/tests/test_ui_helpers.py @@ -45,15 +45,12 @@ def test_tab_label(): assert _run_js_fn("tabLabel", "codex") == "Codex" assert _run_js_fn("tabLabel", "claude") == "Claude" assert _run_js_fn("tabLabel", "grok") == "Grok" - assert _run_js_fn("tabLabel", "grokbot") == "Grok Bot" assert _run_js_fn("tabLabel", "unknown") == "Codex" def test_provider_short_label(): assert _run_js_fn("providerShortLabel", "antigravity") == "Antigr." assert _run_js_fn("providerShortLabel", "codex") == "Codex" - # "Bot": the settings chips split a fixed width six ways and "Grok" is the next chip. - assert _run_js_fn("providerShortLabel", "grokbot") == "Bot" assert _run_js_fn("providerShortLabel", "custom") == "custom" @@ -61,16 +58,15 @@ def test_provider_login_site_and_url(): assert _run_js_fn("providerLoginSite", "claude") == "claude.ai" assert _run_js_fn("providerLoginUrl", "claude") == "https://claude.ai/login" assert _run_js_fn("providerLoginUrl", "antigravity") == "" - # Grok Bot signs in through its desktop app, not a browser: no sign-in button. - assert _run_js_fn("providerLoginUrl", "grokbot") == "" + # Grok signs in through its CLI or the Grok Bot desktop app, not a browser: no sign-in button. + assert _run_js_fn("providerLoginUrl", "grok") == "" def test_dashboard_and_status_urls(): assert _run_js_fn("dashboardUrl", "claude") == "https://claude.ai/settings/usage" assert _run_js_fn("statusUrl", "claude") == "https://status.claude.com/" - assert _run_js_fn("dashboardUrl", "grokbot") == "https://cursor.com/dashboard?tab=billing" - assert _run_js_fn("statusUrl", "grokbot") == "https://status.x.ai/" - assert _run_js_fn("dashboardUrl", "grok") == "https://grok.com/?_s=usage" # Grok Build unchanged + assert _run_js_fn("statusUrl", "grok") == "https://status.x.ai/" + assert _run_js_fn("dashboardUrl", "grok") == "https://grok.com/?_s=usage" def test_status_is_bad(): diff --git a/tools/preview/mock-telemetry.json b/tools/preview/mock-telemetry.json index 8792986..0dc2b9f 100644 --- a/tools/preview/mock-telemetry.json +++ b/tools/preview/mock-telemetry.json @@ -2798,6 +2798,7 @@ "percent": 57.0, "reset": "Resets in 1d 1h", "unit": "percent", + "sublabel": "Grok Bot 41% · Grok Build 16%", "isExtraUsage": false, "formattedUsedText": "57% used", "formattedDetailText": "This week: 18.4M tokens · $11.62", @@ -2808,29 +2809,6 @@ ], "tier": "SuperGrok", "accentColor": "#d0d0d0" - }, - "grokbot": { - "label": "Grok Bot", - "status": "ok", - "source": "grok-bot", - "message": "Grok Bot weekly usage", - "limits": [ - { - "label": "Weekly", - "percent": 38.0, - "reset": "Resets in 3d 4h", - "unit": "percent", - "windowMinutes": 10080, - "isExtraUsage": false, - "formattedUsedText": "38% used", - "formattedDetailText": "", - "formattedDetailLeft": "", - "formattedDetailRight": "", - "pacePercent": 54.0 - } - ], - "tier": "SuperGrok week", - "accentColor": "#e6e6e6" } }, "diagnostics": {}, @@ -2840,8 +2818,7 @@ "claude", "gemini", "antigravity", - "grok", - "grokbot" + "grok" ], "refreshIntervalMinutes": 5, "notificationsEnabled": true,