Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:<b64>`, `scoped:v1:<64 hex>:<b64>`, 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.
Expand Down
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 12 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
30 changes: 25 additions & 5 deletions io.github.dlansama.tallybar/contents/code/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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] = {}

Expand Down Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions io.github.dlansama.tallybar/contents/code/parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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",
Expand Down
Loading
Loading