From 21f223848477245a4ff3927cf44618a2177a9dbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=B6nermann?= Date: Fri, 8 May 2026 02:04:12 +0200 Subject: [PATCH 1/3] feat(loaders): extract Codex rate-limits + per-source merge for scan refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `loadCodexRateLimits()` which scans ~/.codex/sessions newest-first for the most recent token_count event with a `rate_limits` payload, then projects it into a `CodexRateLimits` snapshot consumed by the dashboard and the desktop widget. Stale snapshots (resets_at in the past) are zeroed so a quiet user doesn't see a near-100% utilization that has silently rolled over since Codex last wrote to disk. `buildDashboardData` accepts the snapshot as a second arg and surfaces it on `DashboardData.codexRateLimits`. The CLI no longer persists Codex events to the store (they're cumulative-totals re-derived on every scan, so persisting double-counts) — `mergeFreshSourceEvents` overlays fresh Codex events on top of the persisted store at read time. Reconciled with master's audit-sweep work: - keeps `isValidTimestamp` in the codex loader entrypoint - exports `normalizeUsage` / `subtractUsage` / `RawUsage` for testing - preserves `writeJsonAndExit` (Bun --compile stdout flush on Windows) Test coverage: - normalizeUsage / subtractUsage unit tests (master) - loadCodexRateLimits integration tests covering staleness, missing rate_limits, plan_type=null (API-key auth) - loadCodexEvents integration test for cumulative-delta + cached-input splitting - aggregator pass-through smoke tests for codexRateLimits Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-04-30-codex-rate-limits-pill-toggle.md | 1269 +++++++++++++++++ scripts/inline-dashboard-icon.mjs | 23 + src/aggregator.test.ts | 20 +- src/aggregator.ts | 7 +- src/event-merge.test.ts | 33 + src/event-merge.ts | 13 + src/index.ts | 29 +- src/loaders/__fixtures__/README.md | 10 + .../codex-rate-limits-sample.jsonl | 1 + src/loaders/codex.test.ts | 237 ++- src/loaders/codex.ts | 102 +- src/loaders/index.ts | 23 +- src/types.ts | 44 + widget/index.html | 41 +- widget/src-tauri/capabilities/default.json | 2 + widget/src/main.ts | 353 ++++- widget/src/source-toggle.ts | 60 + widget/src/styles.css | 426 +++++- widget/src/types.ts | 29 +- widget/src/ui.ts | 673 ++++++++- 20 files changed, 3203 insertions(+), 192 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-30-codex-rate-limits-pill-toggle.md create mode 100644 scripts/inline-dashboard-icon.mjs create mode 100644 src/event-merge.test.ts create mode 100644 src/event-merge.ts create mode 100644 src/loaders/__fixtures__/README.md create mode 100644 src/loaders/__fixtures__/codex-rate-limits-sample.jsonl create mode 100644 widget/src/source-toggle.ts diff --git a/docs/superpowers/plans/2026-04-30-codex-rate-limits-pill-toggle.md b/docs/superpowers/plans/2026-04-30-codex-rate-limits-pill-toggle.md new file mode 100644 index 0000000..45815ac --- /dev/null +++ b/docs/superpowers/plans/2026-04-30-codex-rate-limits-pill-toggle.md @@ -0,0 +1,1269 @@ +# Codex Rate Limits + Pill Source Toggle Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Read Codex rate limits from local JSONL sessions, expose them through the sidecar to the widget, and replace the expanded panel's "Claude.ai Subscription" section with two source toggles (Claude Code / Codex) that drive what the compact pill displays — single-mode keeps current layout, dual-mode shows two stacked rows with brand logos. + +**Architecture:** Codex CLI writes `rate_limits` snapshots to `~/.codex/sessions/**/rollout-*.jsonl` on every API exchange. A new loader function `loadCodexRateLimits()` reads the latest entry from the most recently modified session, normalizes it to match the existing Claude `WindowUsage` shape (Unix-seconds → ISO string, `used_percent` → `utilization`), and surfaces it through `DashboardData.codexRateLimits`. The sidecar JSON is consumed by the widget's existing `fetch_local_usage` Tauri command, which projects the new field into `LocalUsageSummary.codexUsage`. The widget UI is restructured to support per-source toggles: `localStorage` keeps user preference, `renderCompact()` becomes mode-aware (`single-claude`/`single-codex`/`dual`), and the pill window resizes dynamically. + +**Tech Stack:** TypeScript (sidecar + widget), Rust (Tauri commands + serde DTOs), CSS (pill layout), vitest (sidecar tests). No new dependencies. + +--- + +## Setup + +- [ ] **S0.1: Verify clean working tree, create feature branch** + +Run: +```bash +git status +git checkout master +git pull +git checkout -b feat/codex-rate-limits-pill-toggle +``` + +Expected: clean status, then on new branch. + +- [ ] **S0.2: Capture a real fixture for tests** + +Copy one current Codex JSONL line containing `rate_limits` to a fixture: +```bash +mkdir -p src/loaders/__fixtures__ +LATEST=$(find ~/.codex/sessions -name "*.jsonl" -printf "%T@ %p\n" 2>/dev/null | sort -nr | head -1 | awk '{print $2}') +grep '"rate_limits"' "$LATEST" | tail -1 > src/loaders/__fixtures__/codex-rate-limits-sample.jsonl +``` +Expected: file exists with one JSON line. Inspect that the line has the structure `{ "type": "event_msg", "payload": { "type": "token_count", "rate_limits": { "primary": {...}, "secondary": {...}, "plan_type": "plus", ... } } }`. + +This file is committed so tests run deterministically. + +--- + +## Phase 1 — Backend: Codex Rate Limit Extraction + +> **Test framework:** This project uses `node:test` (Node's native runner), not vitest. `npm test` runs `node --test --import tsx "src/**/*.test.ts"`. New tests should follow the pattern in `src/store.test.ts` / `src/aggregator.test.ts`: `import { test, describe, before, after } from 'node:test'; import assert from 'node:assert/strict';`. Use `assert.strictEqual` / `assert.notStrictEqual` instead of vitest's `expect().toBe()` / `expect().not.toBeNull()`. + +### Task 1: Type definitions + loader function + +**Files:** +- Modify: `src/types.ts` +- Modify: `src/loaders/codex.ts` +- Create: `src/loaders/codex.test.ts` + +- [ ] **Step 1.1: Add `CodexRateLimits` types to `src/types.ts`** + +Append at the end of the file (after `SOURCE_COLORS`): + +```typescript +/// Snapshot of Codex CLI rate-limit state read from the most recent +/// session JSONL. Codex emits this structure on every `token_count` +/// event; we keep only the latest one. Unix-seconds reset times are +/// converted to ISO strings at extraction time so consumers can use the +/// same Date(...) parsing as Claude's WindowUsage. +export interface CodexWindowUsage { + /// 0-100, matches Claude's WindowUsage.utilization semantics. + utilization: number; + /// Window length in minutes (300 for 5h, 10080 for 7d). + windowMinutes: number; + /// ISO 8601 timestamp; null only if Codex emitted a malformed entry. + resetsAt: string | null; +} + +export interface CodexRateLimits { + /// "plus" / "pro" / "team" / "enterprise" / "edu". Null when the + /// user authenticates via OPENAI_API_KEY (pay-as-you-go has no plan + /// limits — UI should treat null as "Codex toggle unavailable"). + planType: string | null; + /// 5-hour rolling window. Null only if missing in the source event. + primary: CodexWindowUsage | null; + /// 7-day rolling window. Null only if missing in the source event. + secondary: CodexWindowUsage | null; + /// ISO timestamp of the source `token_count` event — i.e. the + /// moment of the user's last Codex API call. The widget renders + /// these numbers without an "as of" stamp by user request, but we + /// expose this for future use / debugging. + snapshotAt: string; +} +``` + +- [ ] **Step 1.2: Write failing tests for `loadCodexRateLimits`** + +Create `src/loaders/codex.test.ts`: + +```typescript +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, utimesSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { loadCodexRateLimits } from './codex.js'; + +function makeSession(dir: string, name: string, lines: string[], mtimeSec?: number): string { + const file = path.join(dir, name); + writeFileSync(file, lines.join('\n') + '\n', 'utf-8'); + if (mtimeSec !== undefined) { + utimesSync(file, mtimeSec, mtimeSec); + } + return file; +} + +describe('loadCodexRateLimits', () => { + let tmpHome: string; + const ORIG_HOME = process.env.CODEX_HOME; + + beforeAll(() => { + tmpHome = mkdtempSync(path.join(tmpdir(), 'codex-test-')); + mkdirSync(path.join(tmpHome, 'sessions', '2026', '04', '30'), { recursive: true }); + process.env.CODEX_HOME = tmpHome; + }); + + afterAll(() => { + if (ORIG_HOME === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = ORIG_HOME; + rmSync(tmpHome, { recursive: true, force: true }); + }); + + it('returns null when no sessions exist', async () => { + const result = await loadCodexRateLimits(); + expect(result).toBeNull(); + }); + + it('extracts the latest rate_limits entry from the most recent session', async () => { + const dir = path.join(tmpHome, 'sessions', '2026', '04', '30'); + const event = (usedPrimary: number, ts: string) => JSON.stringify({ + timestamp: ts, + type: 'event_msg', + payload: { + type: 'token_count', + info: null, + rate_limits: { + limit_id: 'codex', + limit_name: null, + primary: { used_percent: usedPrimary, window_minutes: 300, resets_at: 1777521443 }, + secondary: { used_percent: 8.0, window_minutes: 10080, resets_at: 1778051858 }, + credits: null, + plan_type: 'plus', + rate_limit_reached_type: null, + }, + }, + }); + + // Older session — should be ignored + makeSession(dir, 'rollout-old.jsonl', [event(5.0, '2026-04-30T01:00:00.000Z')], 1000); + // Newer session — within it, last rate_limits entry wins + makeSession(dir, 'rollout-new.jsonl', [ + event(20.0, '2026-04-30T01:30:00.000Z'), + event(38.0, '2026-04-30T01:40:00.000Z'), + ], 2000); + + const result = await loadCodexRateLimits(); + expect(result).not.toBeNull(); + expect(result!.planType).toBe('plus'); + expect(result!.primary).not.toBeNull(); + expect(result!.primary!.utilization).toBe(38.0); + expect(result!.primary!.windowMinutes).toBe(300); + expect(result!.primary!.resetsAt).toBe(new Date(1777521443 * 1000).toISOString()); + expect(result!.secondary!.utilization).toBe(8.0); + expect(result!.snapshotAt).toBe('2026-04-30T01:40:00.000Z'); + }); + + it('handles missing rate_limits gracefully', async () => { + const dir = path.join(tmpHome, 'sessions', '2026', '04', '30'); + makeSession(dir, 'rollout-empty.jsonl', [ + JSON.stringify({ timestamp: '2026-04-30T02:00:00.000Z', type: 'session_meta', payload: { cwd: '/tmp' } }), + ], 3000); // newer than other fixtures + + const result = await loadCodexRateLimits(); + // Falls back to whichever session DID have rate_limits — the previous "rollout-new" + expect(result).not.toBeNull(); + expect(result!.snapshotAt).toBe('2026-04-30T01:40:00.000Z'); + }); + + it('handles plan_type null (API-key auth)', async () => { + const dir = path.join(tmpHome, 'sessions', '2026', '04', '30'); + makeSession(dir, 'rollout-apikey.jsonl', [JSON.stringify({ + timestamp: '2026-04-30T03:00:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + rate_limits: { primary: null, secondary: null, plan_type: null }, + }, + })], 4000); + + const result = await loadCodexRateLimits(); + expect(result).not.toBeNull(); + expect(result!.planType).toBeNull(); + expect(result!.primary).toBeNull(); + expect(result!.secondary).toBeNull(); + }); +}); +``` + +- [ ] **Step 1.3: Run tests, confirm they fail** + +Run: `npx vitest run src/loaders/codex.test.ts` +Expected: FAIL — `loadCodexRateLimits is not exported from './codex.js'`. + +- [ ] **Step 1.4: Implement `loadCodexRateLimits` in `src/loaders/codex.ts`** + +Add at the bottom of the file (after `loadCodexEvents`): + +```typescript +import type { CodexRateLimits, CodexWindowUsage } from '../types.js'; +// ^ Add to existing import line at top of file alongside UnifiedTokenEvent. + +function parseRateLimitWindow(raw: unknown): CodexWindowUsage | null { + if (!raw || typeof raw !== 'object') return null; + const r = raw as Record; + const used = typeof r.used_percent === 'number' ? r.used_percent : null; + const windowMin = typeof r.window_minutes === 'number' ? r.window_minutes : null; + const resetsUnix = typeof r.resets_at === 'number' ? r.resets_at : null; + if (used === null || windowMin === null) return null; + return { + utilization: used, + windowMinutes: windowMin, + resetsAt: resetsUnix !== null ? new Date(resetsUnix * 1000).toISOString() : null, + }; +} + +/// Read the most recent rate_limits snapshot Codex has written to its +/// session JSONL files. Returns null when: +/// - no Codex installation is detected +/// - no session contains a rate_limits-bearing event +/// Sessions are scanned newest-first (by mtime); the LAST rate_limits +/// entry within the newest session that contains one wins. +export async function loadCodexRateLimits(): Promise { + const codexDir = getCodexDir(); + if (!codexDir) return null; + + const sessionsDir = path.join(codexDir, 'sessions'); + const files = await glob('**/*.jsonl', { cwd: sessionsDir, absolute: true, stats: true }); + if (files.length === 0) return null; + + // tinyglobby with stats:true returns { name, path, dirent, ... } depending + // on version. Fall back to fs.stat if shape doesn't include mtime. + const withMtime = await Promise.all(files.map(async (entry: any) => { + const filePath = typeof entry === 'string' ? entry : entry.path ?? entry.name; + const { stat } = await import('node:fs/promises'); + const s = await stat(filePath); + return { path: filePath, mtimeMs: s.mtimeMs }; + })); + + withMtime.sort((a, b) => b.mtimeMs - a.mtimeMs); + + const { readFile } = await import('node:fs/promises'); + for (const { path: file } of withMtime) { + let content: string; + try { + content = await readFile(file, 'utf-8'); + } catch { + continue; + } + + let lastSnapshot: CodexRateLimits | null = null; + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + + let entry: Record; + try { + entry = JSON.parse(trimmed); + } catch { + continue; + } + + if (entry.type !== 'event_msg') continue; + const payload = entry.payload as Record | undefined; + if (!payload || payload.type !== 'token_count') continue; + const rl = payload.rate_limits as Record | undefined; + if (!rl) continue; + const ts = typeof entry.timestamp === 'string' ? entry.timestamp : null; + if (!ts) continue; + + lastSnapshot = { + planType: typeof rl.plan_type === 'string' ? rl.plan_type : null, + primary: parseRateLimitWindow(rl.primary), + secondary: parseRateLimitWindow(rl.secondary), + snapshotAt: ts, + }; + } + + if (lastSnapshot) return lastSnapshot; + } + + return null; +} +``` + +- [ ] **Step 1.5: Run tests, confirm they pass** + +Run: `npx vitest run src/loaders/codex.test.ts` +Expected: 4 tests passing. + +- [ ] **Step 1.6: Commit Phase 1 Task 1** + +```bash +git add src/types.ts src/loaders/codex.ts src/loaders/codex.test.ts src/loaders/__fixtures__/ +git commit -m "feat(loaders): extract Codex rate limits from JSONL sessions" +``` + +--- + +### Task 2: Wire rate limits into DashboardData + +**Files:** +- Modify: `src/types.ts` +- Modify: `src/loaders/index.ts` +- Modify: `src/aggregator.ts` +- Modify: `src/index.ts` + +- [ ] **Step 2.1: Add `codexRateLimits` to `DashboardData`** + +Modify `src/types.ts`, in the `DashboardData` interface, add a field after `heatmap`: + +```typescript +export interface DashboardData { + // ... existing fields ... + heatmap: HeatmapCell[]; + /// Live snapshot of Codex CLI rate-limit state. Null when: + /// - Codex isn't installed + /// - the user has no session containing a rate_limits-bearing event + /// - the user authenticates via OPENAI_API_KEY (planType = null) + /// Consumers should treat null as "Codex limits unavailable". + codexRateLimits: CodexRateLimits | null; +} +``` + +- [ ] **Step 2.2: Update `loadAll` to include rate-limits result** + +Modify `src/loaders/index.ts`. Add to `LoadAllResult`: + +```typescript +import type { CodexRateLimits } from '../types.js'; +import { loadCodexRateLimits } from './codex.js'; +// ^ Adjust existing imports. + +export type LoadAllResult = { + events: UnifiedTokenEvent[]; + detected: Source[]; + errors: Array<{ source: Source; error: string }>; + codexRateLimits: CodexRateLimits | null; +}; +``` + +In the `loadAll` function, after the existing `Promise.allSettled` block, add a parallel call for rate limits: + +```typescript +export async function loadAll(quiet = false): Promise { + // ... existing code ... + + // Fetch rate limits concurrently with events. Failures are non-fatal — + // we just degrade to null so the widget hides the Codex toggle. + let codexRateLimits: CodexRateLimits | null = null; + try { + codexRateLimits = await loadCodexRateLimits(); + } catch (err) { + log(pc.yellow(` warn: failed to read codex rate limits: ${String(err)}`)); + } + + events.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + return { events, detected, errors, codexRateLimits }; +} +``` + +(Could be done in parallel with the loaders' Promise.allSettled, but rate-limits read is fast and the loaders dominate.) + +- [ ] **Step 2.3: Pass rate limits through `buildDashboardData`** + +Modify `src/aggregator.ts`. Change `buildDashboardData` signature: + +```typescript +export function buildDashboardData( + events: UnifiedTokenEvent[], + codexRateLimits: import('./types.js').CodexRateLimits | null = null, +): DashboardData { + // ... existing body unchanged until return ... + return { + generated: new Date().toISOString(), + totals: { /* ... */ }, + daily, + // ... existing fields ... + heatmap, + codexRateLimits, + }; +} +``` + +- [ ] **Step 2.4: Wire through in `src/index.ts`** + +Modify `src/index.ts`. Find the `loadAll` call and the `buildDashboardData` calls (there are 3 sites: empty-store JSON branch, main JSON output, and the live-reload closure). Pass `codexRateLimits` through: + +```typescript +const { events: scanned, detected, errors, codexRateLimits } = await loadAll(json); +// ... +if (json) { + process.stdout.write(JSON.stringify(buildDashboardData([], codexRateLimits), null, 2)); + return; +} +// ... +const data = buildDashboardData(store.events, codexRateLimits); +// ... +const reloadDashboardData = async () => { + const { events: fresh, codexRateLimits: freshLimits } = await loadAll(true); + // ... + return buildDashboardData(store.events, freshLimits); +}; +``` + +- [ ] **Step 2.5: Run all backend tests, confirm nothing regressed** + +Run: `npx vitest run` +Expected: all existing tests pass; new codex tests pass; total test count is previous + 4. + +- [ ] **Step 2.6: Manual smoke test of `tokenbbq scan`** + +Run: +```bash +npm run build +node dist/index.js scan | python -c "import sys,json; d=json.load(sys.stdin); print(json.dumps(d.get('codexRateLimits'), indent=2))" +``` +Expected: prints your real Codex rate limits in JSON form (planType, primary.utilization, etc.). + +- [ ] **Step 2.7: Commit Phase 1 Task 2** + +```bash +git add src/types.ts src/loaders/index.ts src/aggregator.ts src/index.ts +git commit -m "feat(scan): expose Codex rate limits in DashboardData" +``` + +--- + +## Phase 2 — Tauri DTO + Command + +### Task 3: Surface Codex usage in `fetch_local_usage` + +**Files:** +- Modify: `widget/src-tauri/src/api_types.rs` +- Modify: `widget/src-tauri/src/commands.rs` + +- [ ] **Step 3.1: Add `CodexUsage` DTO to `api_types.rs`** + +Append after the `ClaudeUsageResponse` block: + +```rust +/// Mirror of TokenBBQ's CodexRateLimits TS interface. Field names use +/// camelCase to match the JSON the sidecar emits (TS interface uses +/// camelCase; serde_json passes them through verbatim because we read +/// via the projection in fetch_local_usage rather than typed deserialization). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexWindowUsage { + pub utilization: f64, + pub window_minutes: u32, + pub resets_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexUsage { + pub plan_type: Option, + pub primary: Option, + pub secondary: Option, + pub snapshot_at: String, +} +``` + +Then extend `LocalUsageSummary`: + +```rust +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalUsageSummary { + pub generated: String, + pub today_date: Option, + pub today_tokens: u64, + pub week_tokens: u64, + pub today_by_source: Vec, + /// Live Codex rate-limit snapshot, projected from the sidecar JSON. + /// None when Codex isn't installed, no rate-limits event was ever + /// emitted, or the user has API-key auth (plan_type null). + pub codex_usage: Option, +} +``` + +- [ ] **Step 3.2: Project `codexRateLimits` from sidecar output in `fetch_local_usage`** + +Modify `widget/src-tauri/src/commands.rs`. Update the imports: + +```rust +use crate::api_types::{ClaudeUsageResponse, CodexUsage, LocalUsageSummary, Settings, SettingsDisplay, SourceSpend}; +``` + +In `fetch_local_usage`, after the `today_by_source` projection block (~line 384), add: + +```rust +let codex_usage: Option = raw + .get("codexRateLimits") + .and_then(|v| if v.is_null() { None } else { Some(v.clone()) }) + .and_then(|v| serde_json::from_value::(v).ok()); +``` + +Then update the final `Ok(LocalUsageSummary { ... })` to include `codex_usage`. + +- [ ] **Step 3.3: Build the sidecar and the widget** + +Run: +```bash +npm run build:sidecar 2>&1 | tail -5 +cd widget && npm run tauri build -- --debug 2>&1 | tail -5 +cd .. +``` +Expected: both succeed without errors. (If `build:sidecar` script doesn't exist, use `npm run build` from repo root and verify `dist/index.js` is fresh.) + +- [ ] **Step 3.4: Smoke-check the Tauri command output** + +Verify the new field arrives at the widget by adding a temporary `console.log("codexUsage:", local.codexUsage)` in `widget/src/main.ts` `fetchLocalUsage()`, run the widget in dev (`npm run tauri dev` from `widget/`), open DevTools, and inspect that `codexUsage` is the expected object. + +Remove the `console.log` before committing. + +- [ ] **Step 3.5: Commit Phase 2** + +```bash +git add widget/src-tauri/src/api_types.rs widget/src-tauri/src/commands.rs +git commit -m "feat(widget/tauri): expose Codex rate limits via fetch_local_usage" +``` + +--- + +## Phase 3 — Widget Data Layer + Toggle State + +### Task 4: TypeScript types + source-toggle state + +**Files:** +- Modify: `widget/src/types.ts` +- Modify: `widget/src/main.ts` +- Create: `widget/src/source-toggle.ts` + +- [ ] **Step 4.1: Add Codex types to widget** + +Modify `widget/src/types.ts`. Add: + +```typescript +export interface CodexWindowUsage { + utilization: number; + windowMinutes: number; + resetsAt: string | null; +} + +export interface CodexUsage { + planType: string | null; + primary: CodexWindowUsage | null; + secondary: CodexWindowUsage | null; + snapshotAt: string; +} +``` + +Then update `LocalUsageSummary`: + +```typescript +export interface LocalUsageSummary { + generated: string; + todayDate: string | null; + todayTokens: number; + weekTokens: number; + todayBySource: { source: string; tokens: number }[]; + codexUsage: CodexUsage | null; +} +``` + +(Adjust to whatever fields already exist — only add `codexUsage`.) + +- [ ] **Step 4.2: Create `widget/src/source-toggle.ts`** + +```typescript +/// User preference for which sources the pill should display. +/// "claude" = Claude Code Subscription only (current default behavior) +/// "codex" = Codex only +/// "both" = stacked dual-mode (pill is taller) +export type SourceMode = 'claude' | 'codex' | 'both'; + +const STORAGE_KEY_CLAUDE = 'tokenbbq-show-claude'; +const STORAGE_KEY_CODEX = 'tokenbbq-show-codex'; + +export interface SourceToggleState { + claude: boolean; + codex: boolean; +} + +/// Read the toggle state from localStorage. Defaults: Claude on, Codex +/// off (matching legacy behavior — we don't auto-enable Codex on first +/// run because not every user has Codex installed; we want the pill +/// to look identical until they explicitly opt in). +export function loadToggleState(): SourceToggleState { + const claude = localStorage.getItem(STORAGE_KEY_CLAUDE); + const codex = localStorage.getItem(STORAGE_KEY_CODEX); + return { + claude: claude === null ? true : claude === '1', + codex: codex === '1', + }; +} + +export function saveToggleState(state: SourceToggleState): void { + localStorage.setItem(STORAGE_KEY_CLAUDE, state.claude ? '1' : '0'); + localStorage.setItem(STORAGE_KEY_CODEX, state.codex ? '1' : '0'); +} + +/// Resolve the effective render mode given user toggles AND data +/// availability. If the user toggled Codex on but the sidecar reports +/// codexUsage=null (no plan / no data), we silently fall back so the +/// pill never renders empty rows. +export function resolveMode( + state: SourceToggleState, + hasClaudeData: boolean, + hasCodexData: boolean, +): SourceMode { + const effClaude = state.claude && hasClaudeData; + const effCodex = state.codex && hasCodexData; + if (effClaude && effCodex) return 'both'; + if (effCodex) return 'codex'; + return 'claude'; // default — matches legacy behavior even if !hasClaudeData +} +``` + +- [ ] **Step 4.3: Wire toggle state into `main.ts`** + +Modify `widget/src/main.ts`. Top-level imports: + +```typescript +import { loadToggleState, saveToggleState, resolveMode, type SourceToggleState } from "./source-toggle"; +``` + +Add module-level state (near `lastUsageJson`): + +```typescript +let toggleState: SourceToggleState = loadToggleState(); +``` + +In `init()`, after the existing load/setup but BEFORE `startPolling`, no change needed yet — the toggle UI is rendered in Phase 4 inside renderExpanded. + +Don't yet wire toggle event handlers — that comes in Phase 4 Task 5. + +- [ ] **Step 4.4: Commit Phase 3** + +```bash +git add widget/src/types.ts widget/src/source-toggle.ts widget/src/main.ts +git commit -m "feat(widget): add Codex types and source-toggle state module" +``` + +--- + +## Phase 4 — Expanded Panel: Replace Subscription Section with Toggles + +### Task 5: Toggle-row UI in expanded panel + +**Files:** +- Modify: `widget/src/ui.ts` +- Modify: `widget/src/styles.css` +- Modify: `widget/src/main.ts` + +- [ ] **Step 5.1: Add toggle-row HTML helper to `ui.ts`** + +In `widget/src/ui.ts`, add helper functions and brand-icon constants near the top (after `clockSvg`): + +```typescript +const claudeBadgeSvg = ``; +// ^ Placeholder; replaced with real Claude Code mark in Phase 6 Task 8. +const codexBadgeSvg = ``; +// ^ Placeholder; replaced with real OpenAI/Codex mark in Phase 6 Task 8. + +function toggleRowHtml( + id: string, + label: string, + logoSvg: string, + checked: boolean, + disabled: boolean, + hint?: string, +): string { + return ` +
+ + ${label}${hint ? `${hint}` : ''} + +
`; +} +``` + +- [ ] **Step 5.2: Replace Subscription rows with toggles in `renderExpanded`** + +In `widget/src/ui.ts`, modify `renderExpanded` to take the toggle state + Codex data, and render toggle rows where the Claude windows used to be: + +```typescript +export function renderExpanded( + usage: ClaudeUsageResponse, + local: LocalUsageSummary | null = null, + toggleState: { claude: boolean; codex: boolean } = { claude: true, codex: false }, +): void { + const container = document.getElementById("usage-bars")!; + const codex = local?.codexUsage ?? null; + + const codexAvailable = codex !== null && codex.planType !== null; + const codexHint = codex === null + ? '(no data)' + : (codex.planType === null ? '(API key — no plan)' : ''); + + let html = `
Pill displays
`; + html += `
`; + html += toggleRowHtml('toggle-claude', 'Claude Code', claudeBadgeSvg, toggleState.claude, false); + html += toggleRowHtml('toggle-codex', 'Codex', codexBadgeSvg, toggleState.codex && codexAvailable, !codexAvailable, codexHint); + html += `
`; + + // Extra Usage panel — kept as before, since it's claude.ai paid credits + if (usage.extra_usage && usage.extra_usage.is_enabled) { + // ... existing extra_usage block (unchanged) ... + } + + if (local) { + html += renderLocalExpandedHtml(local); + } + + container.innerHTML = html; + + if (document.getElementById("expanded-view")!.classList.contains("visible")) { + requestAnimationFrame(() => requestAnimationFrame(() => { void fitExpandedToContent(); })); + } +} +``` + +(Keep the existing `usageRowHtml` function in the file unused for now — we'll delete it in cleanup once Phase 6 is verified, in case we want to bring details back.) + +- [ ] **Step 5.3: Add toggle-row CSS to `styles.css`** + +In `widget/src/styles.css`, append: + +```css +/* Source-toggle list — replaces the old "Claude.ai Subscription" rows + in the expanded panel. Each row is logo + label + iOS-style switch. */ +.source-toggle-list { + display: flex; + flex-direction: column; + gap: 8px; + padding: 4px 0 12px; +} +.source-toggle-row { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 10px; + border-radius: 8px; + background: var(--surface-2, rgba(255, 255, 255, 0.04)); +} +.source-toggle-row.disabled { + opacity: 0.5; +} +.source-toggle-logo { + width: 18px; + height: 18px; + color: var(--text-secondary, #aaa); + display: inline-flex; + flex-shrink: 0; +} +.source-toggle-logo svg { + width: 100%; + height: 100%; +} +.source-toggle-label { + flex: 1; + font-size: 13px; + color: var(--text-primary); + display: flex; + flex-direction: column; +} +.source-toggle-hint { + font-size: 10px; + color: var(--text-tertiary, #666); + margin-top: 1px; +} +.source-toggle-switch { + position: relative; + width: 32px; + height: 18px; + cursor: pointer; +} +.source-toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} +.source-toggle-slider { + position: absolute; + inset: 0; + background: var(--border-color, #444); + border-radius: 18px; + transition: background 0.15s; +} +.source-toggle-slider::before { + content: ''; + position: absolute; + width: 14px; + height: 14px; + left: 2px; + top: 2px; + background: white; + border-radius: 50%; + transition: transform 0.15s; +} +.source-toggle-switch input:checked + .source-toggle-slider { + background: var(--accent, #74aa9c); +} +.source-toggle-switch input:checked + .source-toggle-slider::before { + transform: translateX(14px); +} +.source-toggle-switch input:disabled ~ .source-toggle-slider { + cursor: not-allowed; +} +``` + +- [ ] **Step 5.4: Wire toggle change events in `main.ts`** + +In `widget/src/main.ts`, update `fetchUsage` to pass toggle state and the existing `renderExpanded` call sites: + +```typescript +async function fetchUsage(): Promise { + try { + const usage = await invoke("fetch_usage"); + const json = JSON.stringify(usage); + if (json === lastUsageJson) return; + lastUsageJson = json; + renderCompact(usage, lastLocal, toggleState); + renderExpanded(usage, lastLocal, toggleState); + } catch (e) { + renderError(String(e)); + } +} +``` + +(`renderCompact` signature change is in Phase 5 — for now stub-call it with extra args; TS will complain, that's fine for this incremental step.) + +In `setupEventListeners()`, add a delegated handler on `usage-bars`: + +```typescript +document.getElementById("usage-bars")!.addEventListener("change", (e) => { + const target = e.target as HTMLInputElement; + if (target.id === "toggle-claude") toggleState.claude = target.checked; + else if (target.id === "toggle-codex") toggleState.codex = target.checked; + else return; + saveToggleState(toggleState); + // Re-render: re-issue last data through new mode without an extra fetch. + if (lastUsageJson) { + try { + const usage = JSON.parse(lastUsageJson) as ClaudeUsageResponse; + renderCompact(usage, lastLocal, toggleState); + renderExpanded(usage, lastLocal, toggleState); + } catch {} + } +}); +``` + +Add `saveToggleState` to imports. + +- [ ] **Step 5.5: Manual smoke test** + +Run `npm run tauri dev` from `widget/`. Open expanded view. Verify: +- "Pill displays" section shows two rows: Claude Code (on), Codex (off if no Codex data, or on/off-able if Codex data present). +- Toggling Claude off and Codex on persists across widget restart. +- The pill itself doesn't yet change layout (Phase 5+ work). + +- [ ] **Step 5.6: Commit Phase 4** + +```bash +git add widget/src/ui.ts widget/src/styles.css widget/src/main.ts +git commit -m "feat(widget): replace Subscription section with source toggles" +``` + +--- + +## Phase 5 — Pill: Generalize for Single-Source (Codex) + +### Task 6: Mode-aware `renderCompact` + +**Files:** +- Modify: `widget/src/ui.ts` +- Modify: `widget/src/main.ts` + +- [ ] **Step 6.1: Refactor `renderCompact` to accept `(usage, local, toggleState)`** + +Replace the current `renderCompact` in `widget/src/ui.ts` with a mode dispatcher. Single-mode keeps the existing layout exactly — only the data source changes: + +```typescript +import { resolveMode, type SourceToggleState } from './source-toggle'; + +export function renderCompact( + usage: ClaudeUsageResponse, + local: LocalUsageSummary | null, + toggleState: SourceToggleState, +): void { + const codex = local?.codexUsage ?? null; + const hasClaude = !!(usage.five_hour || usage.seven_day); + const hasCodex = codex !== null && codex.planType !== null && (codex.primary !== null || codex.secondary !== null); + const mode = resolveMode(toggleState, hasClaude, hasCodex); + + const fiveHour = document.getElementById("five-hour-compact")!; + const sevenDay = document.getElementById("seven-day-compact")!; + const fiveHourLabel = document.getElementById("five-hour-label")!; + const sevenDayLabel = document.getElementById("seven-day-label")!; + + if (mode === 'codex' && codex) { + const fhPct = codex.primary?.utilization ?? 0; + const sdPct = codex.secondary?.utilization ?? 0; + fiveHour.textContent = `${Math.round(fhPct)}%`; + fiveHour.style.color = utilizationColor(fhPct); + sevenDay.textContent = `${Math.round(sdPct)}%`; + sevenDay.style.color = utilizationColor(sdPct); + fiveHourLabel.textContent = formatHoursCompact(codex.primary?.resetsAt ?? null) || "5h"; + sevenDayLabel.textContent = formatDaysCompact(codex.secondary?.resetsAt ?? null) || "7d"; + return; + } + + if (mode === 'both') { + // Dual-mode rendering — handled by renderCompactDual (Phase 6). + // Fall through to single-claude until Phase 6 lands. + } + + // Default / single-claude mode — original behavior. + const fhPct = usage.five_hour?.utilization ?? 0; + const sdPct = usage.seven_day?.utilization ?? 0; + fiveHour.textContent = `${Math.round(fhPct)}%`; + fiveHour.style.color = utilizationColor(fhPct); + sevenDay.textContent = `${Math.round(sdPct)}%`; + sevenDay.style.color = utilizationColor(sdPct); + fiveHourLabel.textContent = formatHoursCompact(usage.five_hour?.resets_at ?? null) || "5h"; + sevenDayLabel.textContent = formatDaysCompact(usage.seven_day?.resets_at ?? null) || "7d"; +} +``` + +- [ ] **Step 6.2: Smoke test single-Codex mode** + +Toggle Claude off + Codex on in expanded view. Pill should now show your Codex 5h/7d percentages (38%, 11% or whatever's current). No layout change — same single-line pill. The TokenBBQ flame icon stays. + +- [ ] **Step 6.3: Commit Phase 5** + +```bash +git add widget/src/ui.ts widget/src/main.ts +git commit -m "feat(widget): pill renders Codex single-source when toggled" +``` + +--- + +## Phase 6 — Pill: Dual-Mode Layout + +### Task 7: HTML/CSS restructure for stacked dual-mode + +**Files:** +- Modify: `widget/index.html` +- Modify: `widget/src/styles.css` +- Modify: `widget/src/ui.ts` + +- [ ] **Step 7.1: Add a hidden "second row" structure to `index.html`** + +In `widget/index.html`, replace the `#compact-view .pill` body. Goal: the pill has a `.pill-rows` wrapper containing one or two `.pill-row` elements. The first row is the existing structure; the second row is duplicated for dual-mode and hidden by default. + +```html +
+ +
+
+ +
+
+ + 5h +
+
+ + 7d +
+
+
+ +
+ + +
+ +
+
+``` + +- [ ] **Step 7.2: CSS for dual-mode rows** + +In `widget/src/styles.css`, find the existing `.pill` block (the compact-view styles). Adjust: + +```css +/* Compact-pill rows: vertical stack when dual-mode is active. The + .pill-rows container aligns the burn-rate icon (left) with whichever + row(s) follow. In single-mode there's one row, height stays 64px; + dual-mode adds a second row and the host window grows to ~110px + via setCompactSize() in main.ts. */ +.pill-rows { + display: flex; + flex-direction: column; + justify-content: center; + gap: 4px; + flex: 1; +} +.pill-row { + display: flex; + align-items: center; + gap: 8px; +} +.pill-row-logo { + width: 14px; + height: 14px; + display: inline-flex; + color: var(--text-secondary, #aaa); + flex-shrink: 0; +} +.pill-row-logo svg { + width: 100%; + height: 100%; +} +.pill-row-logo[hidden] { + display: none; +} +/* Tighter metric layout in dual-mode so two rows fit without ballooning. */ +.pill.dual-mode .pill-metric-value { + font-size: 14px; +} +.pill.dual-mode .pill-metric-label { + font-size: 9px; +} +``` + +(Adjust selectors/var names to match the actual existing CSS — read the file first and align.) + +- [ ] **Step 7.3: Define `COMPACT_SIZE_DUAL` and a `setCompactSize` helper in `ui.ts`** + +In `widget/src/ui.ts`, replace the const `COMPACT_SIZE` with: + +```typescript +const COMPACT_SIZE_SINGLE = { width: 320, height: 64 }; +const COMPACT_SIZE_DUAL = { width: 320, height: 110 }; + +export function compactSizeForMode(mode: SourceMode): { width: number; height: number } { + return mode === 'both' ? COMPACT_SIZE_DUAL : COMPACT_SIZE_SINGLE; +} +``` + +Replace usage of `COMPACT_SIZE` in `setViewState` with a call that resolves at runtime — but since `setViewState` doesn't yet know the mode, accept it as an optional param: + +```typescript +export async function setViewState(state: ViewState, mode: SourceMode = 'claude'): Promise { + // ... existing code ... + if (state === "compact") { + settings.classList.remove("visible"); + panel.classList.remove("visible"); + pill.classList.remove("hidden-pill"); + pill.classList.toggle("dual-mode", mode === 'both'); + const sz = compactSizeForMode(mode); + await win.setSize(new LogicalSize(sz.width, sz.height)); + } + // ... rest unchanged ... +} +``` + +In `main.ts`, callers of `setViewState("compact")` now pass the resolved mode. Add a helper: + +```typescript +function currentMode(): SourceMode { + const local = lastLocal; + const usage = lastUsageJson ? JSON.parse(lastUsageJson) as ClaudeUsageResponse : null; + const hasClaude = !!(usage?.five_hour || usage?.seven_day); + const hasCodex = !!(local?.codexUsage && local.codexUsage.planType !== null + && (local.codexUsage.primary || local.codexUsage.secondary)); + return resolveMode(toggleState, hasClaude, hasCodex); +} +``` + +Then update collapse() and toggle-change handler to call `setViewState("compact", currentMode())`. + +- [ ] **Step 7.4: Implement dual-mode rendering in `renderCompact`** + +Extend `renderCompact` to populate the second row when `mode === 'both'`: + +```typescript +if (mode === 'both' && codex) { + // Show the second row. + document.getElementById('pill-row-secondary')!.removeAttribute('hidden'); + document.getElementById('pill-row-logo-primary')!.removeAttribute('hidden'); + document.getElementById('pill-row-logo-primary')!.innerHTML = claudeBadgeSvg; + document.getElementById('pill-row-logo-secondary')!.innerHTML = codexBadgeSvg; + + // Primary row = Claude + const fhPctC = usage.five_hour?.utilization ?? 0; + const sdPctC = usage.seven_day?.utilization ?? 0; + fiveHour.textContent = `${Math.round(fhPctC)}%`; + fiveHour.style.color = utilizationColor(fhPctC); + sevenDay.textContent = `${Math.round(sdPctC)}%`; + sevenDay.style.color = utilizationColor(sdPctC); + fiveHourLabel.textContent = formatHoursCompact(usage.five_hour?.resets_at ?? null) || "5h"; + sevenDayLabel.textContent = formatDaysCompact(usage.seven_day?.resets_at ?? null) || "7d"; + + // Secondary row = Codex + const fhPctX = codex.primary?.utilization ?? 0; + const sdPctX = codex.secondary?.utilization ?? 0; + document.getElementById('five-hour-compact-2')!.textContent = `${Math.round(fhPctX)}%`; + (document.getElementById('five-hour-compact-2') as HTMLElement).style.color = utilizationColor(fhPctX); + document.getElementById('seven-day-compact-2')!.textContent = `${Math.round(sdPctX)}%`; + (document.getElementById('seven-day-compact-2') as HTMLElement).style.color = utilizationColor(sdPctX); + document.getElementById('five-hour-label-2')!.textContent = formatHoursCompact(codex.primary?.resetsAt ?? null) || "5h"; + document.getElementById('seven-day-label-2')!.textContent = formatDaysCompact(codex.secondary?.resetsAt ?? null) || "7d"; + return; +} + +// In single-mode: hide secondary row + logos +document.getElementById('pill-row-secondary')!.setAttribute('hidden', ''); +document.getElementById('pill-row-logo-primary')!.setAttribute('hidden', ''); +``` + +- [ ] **Step 7.5: Smoke test dual-mode** + +Toggle Claude on + Codex on. Verify: +- Pill window grows to ~110px height. +- Two stacked rows: top Claude, bottom Codex. +- Each row has a small logo (placeholder shapes for now). +- Toggling back to single-mode shrinks the window. + +- [ ] **Step 7.6: Commit Phase 6 Task 7** + +```bash +git add widget/index.html widget/src/styles.css widget/src/ui.ts widget/src/main.ts +git commit -m "feat(widget): pill dual-mode renders stacked Claude + Codex rows" +``` + +--- + +### Task 8: Real brand SVGs + +**Files:** +- Modify: `widget/src/ui.ts` + +- [ ] **Step 8.1: Replace placeholder SVGs with real brand marks** + +In `widget/src/ui.ts`, replace the placeholder `claudeBadgeSvg` / `codexBadgeSvg` with monochrome marks. Use simple outline paths so they tint via `currentColor`: + +```typescript +// Anthropic / Claude C-mark — simplified outline. +const claudeBadgeSvg = ``; + +// OpenAI knot — simplified mark. +const codexBadgeSvg = ``; +``` + +(If you have official brand assets the user prefers, drop them as files in `widget/src/assets/` and import as URLs — but for the toggle-row + pill-row context, inline SVGs that inherit color via `currentColor` integrate better with the dark/light theme system.) + +- [ ] **Step 8.2: Smoke test brand visibility in dark + light themes** + +Run `npm run tauri dev`. Toggle theme between dark and light in Settings. Verify both logos are visible and adopt the foreground color. + +- [ ] **Step 8.3: Commit Phase 6 Task 8** + +```bash +git add widget/src/ui.ts +git commit -m "feat(widget): real Claude + OpenAI brand marks for pill dual-mode" +``` + +--- + +## Phase 7 — Verification & Polish + +### Task 9: End-to-end verification + edge cases + +**Files:** +- Modify (potentially): `widget/src/main.ts`, `widget/src/ui.ts` + +- [ ] **Step 9.1: Build everything from clean state** + +```bash +git status # confirm clean +npm run build +cd widget && npm run tauri build -- --debug +cd .. +``` + +Expected: both succeed. + +- [ ] **Step 9.2: Run the WHOLE program (sidecar + widget together)** + +This is a Multi-Surface product (per project memory). Verify all surfaces: +- Start widget — should auto-poll sidecar. +- Open `npx tokenbbq dashboard` in another terminal — verify the dashboard renders correctly with the new `codexRateLimits` field present in JSON (browser DevTools → check `__latestData.codexRateLimits`). +- Verify both work simultaneously without sidecar conflicts. + +- [ ] **Step 9.3: Manual UAT scenarios** + +Test each scenario in the running widget: + +1. **Single Claude (default first-launch):** Pill = legacy layout, no logos, 5h%/7d% from claude.ai. +2. **Single Codex:** Toggle Claude off, Codex on. Pill = legacy layout (no logos), values from local Codex JSONL. +3. **Dual:** Both toggles on. Pill = stacked rows with logos, claude on top, codex below. Window taller. +4. **Codex unavailable:** If you remove `~/.codex` temporarily (or rename it), Codex toggle is disabled with hint "(no data)". Toggling it has no effect; pill stays in single-claude mode. +5. **Both off (edge):** If user toggles both off, pill defaults to single-claude (resolveMode fallback). +6. **Toggle while in expanded view:** Switch toggles in expanded view — pill behind isn't visible. Collapse, verify pill matches new toggle state. Window resize is smooth. +7. **Window-anchor preservation:** Toggle into dual-mode while pill is at right edge of screen — pill should stay anchored, growing downward, not jumping. + +If scenario 7 fails (pill jumps), the dual-mode resize needs to compensate. Find the anchor logic (recent commit `bbb5064 feat(widget): anchor pill on right edge at 60% screen height`) and adjust to re-anchor after `setSize` when collapsing into dual-mode. + +- [ ] **Step 9.4: Edge-case fixups (only if issues found in 9.3)** + +Document any deviations from expected behavior. Fix in-place. Re-test the affected scenario. + +- [ ] **Step 9.5: Final commit + push** + +```bash +git status # should be clean if no extra fixups needed +git push -u origin feat/codex-rate-limits-pill-toggle +``` + +If the user wants a PR rather than direct merge, `gh pr create` from there. + +--- + +## Risks / Eigenheiten + +- **Codex rate-limit reads are filesystem reads.** Sidecar is short-lived (~2s), so cost is negligible. But if a user has thousands of session files, the mtime sort + read of the latest file is O(n) on number of files. We accept this — Codex sessions are typically <500 in normal use. +- **localStorage is per-widget-window.** If the user reinstalls / clears app data, toggle state resets to defaults (Claude on, Codex off). +- **`codexRateLimits` is null for API-key auth.** Toggle is disabled with a hint; pill never renders empty Codex rows. +- **Snapshot freshness:** Codex rate-limits represent the state at the user's last Codex API call. If they haven't used Codex for hours, the percentage is stale — but never artificially low (`used_percent` only goes UP within a window). User explicitly opted out of an "as of HH:MM" hint; we show the value bare. +- **`fix/windows-console-flash` branch** is unrelated — we branched from master. If that branch has unmerged commits the widget still needs, rebase/merge it before testing on master to avoid regressing the Windows console flash fix. + +--- + +## Self-Review Checklist Results + +- **Spec coverage:** All four spec items covered — backend extraction (Phase 1), Tauri pipe (Phase 2), expanded toggles (Phase 4), pill single+dual modes (Phases 5-6), brand logos (Phase 6 Task 8). User-explicit "no as-of stamp" honored throughout. +- **Placeholders:** None — every step has actual code or a concrete command. +- **Type consistency:** `CodexRateLimits` (TS) / `CodexUsage` (Rust) names diverge by language convention but field names align (`planType`/`plan_type`, etc.) via serde rename. Sidecar emits camelCase JSON; Tauri reads via Value projection then typed deserialize. +- **Brand SVGs:** The Step 8.1 marks are simplified — if the user wants licensed/official marks, swap before shipping a public release. diff --git a/scripts/inline-dashboard-icon.mjs b/scripts/inline-dashboard-icon.mjs new file mode 100644 index 0000000..b5a30e0 --- /dev/null +++ b/scripts/inline-dashboard-icon.mjs @@ -0,0 +1,23 @@ +// Build-time embed of the TokenBBQ brand PNG into the CLI bundle. +// Reads widget/src/assets/tokenbbq-icon.png and writes src/dashboard-icon.ts +// with the base64-encoded data URL — so the dashboard HTML can render the +// brand mark without depending on a runtime file path. Same trick as +// scripts/inline-wasm.mjs (which solves the same Bun --compile problem). +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +const repoRoot = path.resolve(import.meta.dirname, '..'); +const src = path.join(repoRoot, 'widget', 'src', 'assets', 'tokenbbq-icon.png'); +const dst = path.join(repoRoot, 'src', 'dashboard-icon.ts'); + +const bytes = readFileSync(src); +const base64 = bytes.toString('base64'); + +const out = `// Auto-generated by scripts/inline-dashboard-icon.mjs — do not edit. +// Source: widget/src/assets/tokenbbq-icon.png (${bytes.length} bytes) +export const DASHBOARD_BRAND_ICON_DATA_URL = +\t'data:image/png;base64,${base64}'; +`; + +writeFileSync(dst, out); +console.log(`[inline-icon] ${src} → ${dst} (${bytes.length} bytes → ${base64.length} chars base64)`); diff --git a/src/aggregator.test.ts b/src/aggregator.test.ts index 1b2e885..04d871d 100644 --- a/src/aggregator.test.ts +++ b/src/aggregator.test.ts @@ -1,7 +1,7 @@ import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import { aggregateByProject, buildDashboardData } from './aggregator.js'; -import { isValidTimestamp, type UnifiedTokenEvent } from './types.js'; +import { isValidTimestamp, type UnifiedTokenEvent, type CodexRateLimits } from './types.js'; function ev(over: Partial = {}): UnifiedTokenEvent { return { @@ -123,3 +123,21 @@ describe('buildDashboardData timestamp safety', () => { assert.equal(out.daily[0].date, '2026-04-20'); }); }); + +describe('buildDashboardData codexRateLimits', () => { + test('passes through codexRateLimits unchanged when provided', () => { + const limits: CodexRateLimits = { + planType: 'plus', + primary: { utilization: 38, windowMinutes: 300, resetsAt: '2026-04-30T05:57:23.000Z' }, + secondary: { utilization: 11, windowMinutes: 10080, resetsAt: '2026-05-06T09:17:38.000Z' }, + snapshotAt: '2026-04-30T01:38:47.383Z', + }; + const out = buildDashboardData([], limits); + assert.equal(out.codexRateLimits, limits); + }); + + test('defaults codexRateLimits to null when omitted', () => { + const out = buildDashboardData([]); + assert.equal(out.codexRateLimits, null); + }); +}); diff --git a/src/aggregator.ts b/src/aggregator.ts index 47e973f..50b6bba 100644 --- a/src/aggregator.ts +++ b/src/aggregator.ts @@ -13,6 +13,7 @@ import type { HeatmapCell, DashboardData, Source, + CodexRateLimits, } from './types.js'; import { emptyTokens, addTokens, totalTokenCount, isValidTimestamp } from './types.js'; @@ -366,7 +367,10 @@ export function aggregateHeatmap(events: UnifiedTokenEvent[]): HeatmapCell[] { return [...map.values()].sort((a, b) => a.date.localeCompare(b.date)); } -export function buildDashboardData(events: UnifiedTokenEvent[]): DashboardData { +export function buildDashboardData( + events: UnifiedTokenEvent[], + codexRateLimits: CodexRateLimits | null = null, +): DashboardData { // Drop events with malformed timestamps at the pipeline boundary so // `dateKey`/`monthKey` (which call `new Date(ts).toISOString()`) can't // throw RangeError("Invalid time value") and take down the whole render. @@ -417,5 +421,6 @@ export function buildDashboardData(events: UnifiedTokenEvent[]): DashboardData { bySourceModel, byProject, heatmap, + codexRateLimits, }; } diff --git a/src/event-merge.test.ts b/src/event-merge.test.ts new file mode 100644 index 0000000..1071c79 --- /dev/null +++ b/src/event-merge.test.ts @@ -0,0 +1,33 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mergeFreshSourceEvents } from './event-merge.js'; +import type { UnifiedTokenEvent } from './types.js'; + +function ev(over: Partial = {}): UnifiedTokenEvent { + return { + source: 'codex', + timestamp: '2026-04-30T10:00:00.000Z', + sessionId: 's', + model: 'gpt-5.5', + tokens: { input: 100, output: 20, cacheCreation: 0, cacheRead: 50, reasoning: 0 }, + costUSD: 0, + ...over, + }; +} + +describe('mergeFreshSourceEvents', () => { + test('uses freshly scanned Codex events instead of stale stored Codex events', () => { + const stored = [ + ev({ sessionId: 'old-codex', tokens: { input: 1, output: 1, cacheCreation: 0, cacheRead: 1, reasoning: 0 } }), + ev({ source: 'claude-code', sessionId: 'stored-claude' }), + ]; + const scanned = [ + ev({ sessionId: 'fresh-codex', tokens: { input: 300, output: 80, cacheCreation: 0, cacheRead: 600, reasoning: 10 } }), + ev({ source: 'claude-code', sessionId: 'scanned-claude' }), + ]; + + const merged = mergeFreshSourceEvents(stored, scanned, ['codex']); + + assert.deepEqual(merged.map(e => e.sessionId), ['stored-claude', 'fresh-codex']); + }); +}); diff --git a/src/event-merge.ts b/src/event-merge.ts new file mode 100644 index 0000000..f87e611 --- /dev/null +++ b/src/event-merge.ts @@ -0,0 +1,13 @@ +import type { Source, UnifiedTokenEvent } from './types.js'; + +export function mergeFreshSourceEvents( + stored: UnifiedTokenEvent[], + scanned: UnifiedTokenEvent[], + freshSources: Source[], +): UnifiedTokenEvent[] { + const fresh = new Set(freshSources); + return [ + ...stored.filter((event) => !fresh.has(event.source)), + ...scanned.filter((event) => fresh.has(event.source)), + ]; +} diff --git a/src/index.ts b/src/index.ts index f166e58..6b32627 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { startServer } from './server.js'; import { startToolWatcher } from './watcher.js'; import { printDailyTable, printMonthlyTable, printSummary } from './cli-output.js'; import { loadStore, appendEvents, type StoreState } from './store.js'; +import { mergeFreshSourceEvents } from './event-merge.js'; function parseArgs(argv: string[]) { const args = argv.slice(2); @@ -106,8 +107,14 @@ async function main(): Promise { log(pc.dim(' Scanning for AI tool usage data...\n')); const store: StoreState = loadStore(); - const { events: scanned, detected, errors } = await loadAll(json); - const added = appendEvents(store, scanned); + const { events: scanned, detected, errors, codexRateLimits } = await loadAll(json); + // Codex emits cumulative-total events that we re-derive deltas from on + // every scan; persisting them double-counts on the next run. Persist + // every other source as before, and merge fresh codex events on top + // of the store at read time only. + const persistable = scanned.filter((e) => e.source !== 'codex'); + const added = appendEvents(store, persistable); + let workingEvents = mergeFreshSourceEvents(store.events, scanned, ['codex']); // Surface loader failures rather than silently dropping them. In JSON // mode (incl. `scan`) we route to stderr so structured stdout stays @@ -116,11 +123,11 @@ async function main(): Promise { console.error(pc.yellow(` warn: loader '${e.source}' failed: ${e.error}`)); } - if (store.events.length === 0) { + if (workingEvents.length === 0) { // In JSON mode (incl. `scan`) emit a valid empty DashboardData rather than // returning silently — embedders can then unconditionally JSON.parse stdout. if (json) { - await writeJsonAndExit(buildDashboardData([])); + await writeJsonAndExit(buildDashboardData([], codexRateLimits)); return; } console.error(pc.yellow('\n No usage data found.')); @@ -129,11 +136,11 @@ async function main(): Promise { return; } - log(pc.dim(`\n Total: ${store.events.length.toLocaleString()} events in store (+ ${added.length} new from ${detected.length} source(s))\n`)); + log(pc.dim(`\n Total: ${workingEvents.length.toLocaleString()} events (+ ${added.length} new persisted from ${detected.length} source(s))\n`)); log(pc.dim(' Calculating costs...')); - await enrichCosts(store.events); + await enrichCosts(workingEvents); - const data = buildDashboardData(store.events); + const data = buildDashboardData(workingEvents, codexRateLimits); if (json) { await writeJsonAndExit(data); @@ -141,10 +148,12 @@ async function main(): Promise { } const reloadDashboardData = async () => { - const { events: fresh } = await loadAll(true); - const addedNow = appendEvents(store, fresh); + const { events: fresh, codexRateLimits: freshLimits } = await loadAll(true); + const addedNow = appendEvents(store, fresh.filter((e) => e.source !== 'codex')); + workingEvents = mergeFreshSourceEvents(store.events, fresh, ['codex']); if (addedNow.length > 0) await enrichCosts(addedNow); - return buildDashboardData(store.events); + await enrichCosts(workingEvents.filter((e) => e.source === 'codex')); + return buildDashboardData(workingEvents, freshLimits); }; switch (command) { diff --git a/src/loaders/__fixtures__/README.md b/src/loaders/__fixtures__/README.md new file mode 100644 index 0000000..a622531 --- /dev/null +++ b/src/loaders/__fixtures__/README.md @@ -0,0 +1,10 @@ +# Loader fixtures + +Reference samples captured from real tool sessions, kept here so future +loader changes have ground-truth examples to reason against. + +These files are **not** consumed by automated tests — `codex.test.ts` +and friends construct synthetic fixtures inline so test data stays +self-contained and deterministic. The samples here exist for human +inspection and as smoke-test inputs when manually verifying a loader +against shape changes upstream. diff --git a/src/loaders/__fixtures__/codex-rate-limits-sample.jsonl b/src/loaders/__fixtures__/codex-rate-limits-sample.jsonl new file mode 100644 index 0000000..e365970 --- /dev/null +++ b/src/loaders/__fixtures__/codex-rate-limits-sample.jsonl @@ -0,0 +1 @@ +{"timestamp":"2026-04-30T10:44:16.788Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":9261351,"cached_input_tokens":8895104,"output_tokens":55689,"reasoning_output_tokens":9803,"total_tokens":9317040},"last_token_usage":{"input_tokens":121269,"cached_input_tokens":120704,"output_tokens":44,"reasoning_output_tokens":0,"total_tokens":121313},"model_context_window":258400},"rate_limits":{"limit_id":"codex","limit_name":null,"primary":{"used_percent":19.0,"window_minutes":300,"resets_at":1777562899},"secondary":{"used_percent":17.0,"window_minutes":10080,"resets_at":1778051858},"credits":null,"plan_type":"plus","rate_limit_reached_type":null}}} diff --git a/src/loaders/codex.test.ts b/src/loaders/codex.test.ts index 56fc34e..6f5f4d9 100644 --- a/src/loaders/codex.test.ts +++ b/src/loaders/codex.test.ts @@ -1,6 +1,18 @@ -import { test, describe } from 'node:test'; +import { test, describe, before, after } from 'node:test'; import assert from 'node:assert/strict'; -import { normalizeUsage, subtractUsage, type RawUsage } from './codex.js'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, utimesSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { loadCodexEvents, loadCodexRateLimits, normalizeUsage, subtractUsage, type RawUsage } from './codex.js'; + +function makeSession(dir: string, name: string, lines: string[], mtimeSec?: number): string { + const file = path.join(dir, name); + writeFileSync(file, lines.join('\n') + '\n', 'utf-8'); + if (mtimeSec !== undefined) { + utimesSync(file, mtimeSec, mtimeSec); + } + return file; +} describe('normalizeUsage', () => { test('reads OpenAI field names and fills total when missing', () => { @@ -90,3 +102,224 @@ describe('subtractUsage — cumulative-total math', () => { assert.deepEqual(f2, { input: 120, cached: 0, output: 80, reasoning: 0, total: 200 }); }); }); + +describe('loadCodexRateLimits', () => { + let tmpHome: string; + const ORIG_HOME = process.env.CODEX_HOME; + + before(() => { + tmpHome = mkdtempSync(path.join(tmpdir(), 'codex-test-')); + mkdirSync(path.join(tmpHome, 'sessions', '2026', '04', '30'), { recursive: true }); + process.env.CODEX_HOME = tmpHome; + }); + + after(() => { + if (ORIG_HOME === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = ORIG_HOME; + rmSync(tmpHome, { recursive: true, force: true }); + }); + + test('returns null when no sessions exist', async () => { + const result = await loadCodexRateLimits(); + assert.strictEqual(result, null); + }); + + test('extracts the latest rate_limits entry from the most recent session', async () => { + const dir = path.join(tmpHome, 'sessions', '2026', '04', '30'); + // Use a reset time far in the future so the stale-window logic + // doesn't kick in for this test — we want to assert the raw + // extracted value, not the staleness fallback. + const future5h = Math.floor(Date.now() / 1000) + 3600; + const future7d = Math.floor(Date.now() / 1000) + 7 * 86400; + const event = (usedPrimary: number, ts: string) => JSON.stringify({ + timestamp: ts, + type: 'event_msg', + payload: { + type: 'token_count', + info: null, + rate_limits: { + limit_id: 'codex', + limit_name: null, + primary: { used_percent: usedPrimary, window_minutes: 300, resets_at: future5h }, + secondary: { used_percent: 8.0, window_minutes: 10080, resets_at: future7d }, + credits: null, + plan_type: 'plus', + rate_limit_reached_type: null, + }, + }, + }); + + // Older session — should be ignored + makeSession(dir, 'rollout-old.jsonl', [event(5.0, '2026-04-30T01:00:00.000Z')], 1000); + // Newer session — within it, last rate_limits entry wins + makeSession(dir, 'rollout-new.jsonl', [ + event(20.0, '2026-04-30T01:30:00.000Z'), + event(38.0, '2026-04-30T01:40:00.000Z'), + ], 2000); + + const result = await loadCodexRateLimits(); + assert.notStrictEqual(result, null); + assert.strictEqual(result!.planType, 'plus'); + assert.notStrictEqual(result!.primary, null); + assert.strictEqual(result!.primary!.utilization, 38.0); + assert.strictEqual(result!.primary!.windowMinutes, 300); + assert.strictEqual(result!.primary!.resetsAt, new Date(future5h * 1000).toISOString()); + assert.strictEqual(result!.secondary!.utilization, 8.0); + assert.strictEqual(result!.snapshotAt, '2026-04-30T01:40:00.000Z'); + }); + + test('handles missing rate_limits gracefully', async () => { + const dir = path.join(tmpHome, 'sessions', '2026', '04', '30'); + makeSession(dir, 'rollout-empty.jsonl', [ + JSON.stringify({ timestamp: '2026-04-30T02:00:00.000Z', type: 'session_meta', payload: { cwd: '/tmp' } }), + ], 3000); // newer than other fixtures + + const result = await loadCodexRateLimits(); + // Falls back to whichever session DID have rate_limits — the previous "rollout-new" + assert.notStrictEqual(result, null); + assert.strictEqual(result!.snapshotAt, '2026-04-30T01:40:00.000Z'); + }); + + test('handles plan_type null (API-key auth)', async () => { + const dir = path.join(tmpHome, 'sessions', '2026', '04', '30'); + makeSession(dir, 'rollout-apikey.jsonl', [JSON.stringify({ + timestamp: '2026-04-30T03:00:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + rate_limits: { primary: null, secondary: null, plan_type: null }, + }, + })], 4000); + + const result = await loadCodexRateLimits(); + assert.notStrictEqual(result, null); + assert.strictEqual(result!.planType, null); + assert.strictEqual(result!.primary, null); + assert.strictEqual(result!.secondary, null); + }); + + test('zeroes utilization when the snapshot reset is in the past', async () => { + const dir = path.join(tmpHome, 'sessions', '2026', '04', '30'); + const past = Math.floor(Date.now() / 1000) - 3600; // 1h ago + // Use the highest mtime so this fixture is selected as newest. + makeSession(dir, 'rollout-stale.jsonl', [JSON.stringify({ + timestamp: '2026-04-30T05:00:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + rate_limits: { + primary: { used_percent: 94.0, window_minutes: 300, resets_at: past }, + secondary: { used_percent: 28.0, window_minutes: 10080, resets_at: past }, + plan_type: 'plus', + }, + }, + })], 9000); + + const result = await loadCodexRateLimits(); + assert.notStrictEqual(result, null); + // Snapshot's window has rolled over since it was written → show 0%. + assert.strictEqual(result!.primary!.utilization, 0); + assert.strictEqual(result!.secondary!.utilization, 0); + // resetsAt is nulled when stale so the pill falls back to "5h"/"7d". + assert.strictEqual(result!.primary!.resetsAt, null); + assert.strictEqual(result!.secondary!.resetsAt, null); + }); +}); + +describe('loadCodexEvents', () => { + let tmpHome: string; + const ORIG_HOME = process.env.CODEX_HOME; + + before(() => { + tmpHome = mkdtempSync(path.join(tmpdir(), 'codex-events-test-')); + mkdirSync(path.join(tmpHome, 'sessions', '2026', '04', '30'), { recursive: true }); + process.env.CODEX_HOME = tmpHome; + }); + + after(() => { + if (ORIG_HOME === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = ORIG_HOME; + rmSync(tmpHome, { recursive: true, force: true }); + }); + + test('emits per-turn deltas via subtractUsage and splits cached input', async () => { + const dir = path.join(tmpHome, 'sessions', '2026', '04', '30'); + // Codex emits cumulative `total_token_usage` per turn. The loader uses + // subtractUsage(currTotal, prevTotal) to derive per-turn deltas. The + // cached portion is split out of `input` so `tokens.input` is fresh-only. + makeSession(dir, 'rollout-usage.jsonl', [ + JSON.stringify({ + timestamp: '2026-04-30T10:00:00.000Z', + type: 'turn_context', + payload: { model: 'gpt-5.5' }, + }), + JSON.stringify({ + timestamp: '2026-04-30T10:00:01.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 1000, + cached_input_tokens: 700, + output_tokens: 100, + reasoning_output_tokens: 20, + total_tokens: 1100, + }, + last_token_usage: { + input_tokens: 1000, + cached_input_tokens: 700, + output_tokens: 100, + reasoning_output_tokens: 20, + total_tokens: 1100, + }, + }, + }, + }), + JSON.stringify({ + timestamp: '2026-04-30T10:00:02.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 1200, + cached_input_tokens: 800, + output_tokens: 110, + reasoning_output_tokens: 25, + total_tokens: 1310, + }, + last_token_usage: { + input_tokens: 200, + cached_input_tokens: 100, + output_tokens: 10, + reasoning_output_tokens: 5, + total_tokens: 210, + }, + }, + }, + }), + ], 1000); + + const events = await loadCodexEvents(); + assert.equal(events.length, 2); + // First turn: prev is null, so raw = lastUsage = (1000, cached 700, 100, 20). + // fresh = 1000 - 700 = 300, cacheRead = 700. + assert.deepEqual(events[0].tokens, { + input: 300, + output: 100, + cacheCreation: 0, + cacheRead: 700, + reasoning: 20, + }); + // Second turn: raw = total2 - total1 = (200, 100, 10, 5). + // fresh = 200 - 100 = 100, cacheRead = 100. + assert.deepEqual(events[1].tokens, { + input: 100, + output: 10, + cacheCreation: 0, + cacheRead: 100, + reasoning: 5, + }); + }); +}); diff --git a/src/loaders/codex.ts b/src/loaders/codex.ts index 205e6ab..3478999 100644 --- a/src/loaders/codex.ts +++ b/src/loaders/codex.ts @@ -1,9 +1,9 @@ -import { readFile } from 'node:fs/promises'; +import { readFile, stat } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; import path from 'node:path'; import { glob } from 'tinyglobby'; -import type { UnifiedTokenEvent } from '../types.js'; +import type { UnifiedTokenEvent, CodexRateLimits, CodexWindowUsage } from '../types.js'; import { isValidTimestamp } from '../types.js'; import { resolveProjectRoot } from '../project.js'; @@ -138,9 +138,7 @@ export async function loadCodexEvents(): Promise { raw = subtractUsage(totalUsage, prevTotals); } prevTotals = totalUsage; - if (raw.input === 0 && raw.output === 0 && raw.cached === 0) continue; - - if (raw.input === 0 && raw.output === 0) continue; + if (raw.input === 0 && raw.output === 0 && raw.cached === 0 && raw.reasoning === 0) continue; const extracted = extractModel({ ...payload, info }); if (extracted) currentModel = extracted; @@ -151,7 +149,10 @@ export async function loadCodexEvents(): Promise { // cache. Storing both verbatim double-counts cache reads inside // `input`. Split them so `tokens.input` is fresh-input only — matches // the semantics of every other loader (Claude Code, Gemini, etc.). - const freshInput = Math.max(raw.input - raw.cached, 0); + // Math.min clamps against the rare case where a malformed entry + // reports cached > input (would otherwise produce negative freshInput). + const cachedInput = Math.min(raw.cached, raw.input); + const freshInput = Math.max(raw.input - cachedInput, 0); events.push({ source: 'codex', timestamp, @@ -161,7 +162,7 @@ export async function loadCodexEvents(): Promise { input: freshInput, output: raw.output, cacheCreation: 0, - cacheRead: raw.cached, + cacheRead: cachedInput, reasoning: raw.reasoning, }, costUSD: 0, @@ -173,3 +174,90 @@ export async function loadCodexEvents(): Promise { events.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); return events; } + +function parseRateLimitWindow(raw: unknown): CodexWindowUsage | null { + if (!raw || typeof raw !== 'object') return null; + const r = raw as Record; + const used = typeof r.used_percent === 'number' ? r.used_percent : null; + const windowMin = typeof r.window_minutes === 'number' ? r.window_minutes : null; + const resetsUnix = typeof r.resets_at === 'number' ? r.resets_at : null; + if (used === null || windowMin === null) return null; + const resetsAtMs = resetsUnix !== null ? resetsUnix * 1000 : null; + // If the snapshot's reset time is already in the past, the rolling + // window has rolled over since Codex last wrote rate-limits to disk. + // Codex only persists rate-limits when it makes an API call, so a + // quiet user can have an arbitrarily old snapshot. Treat as 0% used + // — closer to truth than the stale (often near-100%) saved value, + // and matches what `codex /status` shows live in this case. + const isStale = resetsAtMs !== null && resetsAtMs < Date.now(); + return { + utilization: isStale ? 0 : used, + windowMinutes: windowMin, + resetsAt: isStale ? null : (resetsAtMs !== null ? new Date(resetsAtMs).toISOString() : null), + }; +} + +/** + * Read the most recent rate_limits snapshot Codex has written to its + * session JSONL files. Returns null when: + * - no Codex installation is detected + * - no session contains a rate_limits-bearing event + * Sessions are scanned newest-first (by mtime); the LAST rate_limits + * entry within the newest session that contains one wins. + */ +export async function loadCodexRateLimits(): Promise { + const codexDir = getCodexDir(); + if (!codexDir) return null; + + const sessionsDir = path.join(codexDir, 'sessions'); + const files = await glob('**/*.jsonl', { cwd: sessionsDir, absolute: true }); + if (files.length === 0) return null; + + const withMtime = await Promise.all(files.map(async (file) => { + const s = await stat(file); + return { path: file, mtimeMs: s.mtimeMs }; + })); + + withMtime.sort((a, b) => b.mtimeMs - a.mtimeMs); + + for (const { path: file } of withMtime) { + let content: string; + try { + content = await readFile(file, 'utf-8'); + } catch { + continue; + } + + let lastSnapshot: CodexRateLimits | null = null; + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + + let entry: Record; + try { + entry = JSON.parse(trimmed); + } catch { + continue; + } + + if (entry.type !== 'event_msg') continue; + const payload = entry.payload as Record | undefined; + if (!payload || payload.type !== 'token_count') continue; + const rl = payload.rate_limits as Record | undefined; + if (!rl) continue; + const ts = typeof entry.timestamp === 'string' ? entry.timestamp : null; + if (!isValidTimestamp(ts)) continue; + + lastSnapshot = { + planType: typeof rl.plan_type === 'string' ? rl.plan_type : null, + primary: parseRateLimitWindow(rl.primary), + secondary: parseRateLimitWindow(rl.secondary), + snapshotAt: ts, + }; + } + + if (lastSnapshot) return lastSnapshot; + } + + return null; +} diff --git a/src/loaders/index.ts b/src/loaders/index.ts index 79c5e95..120eb0b 100644 --- a/src/loaders/index.ts +++ b/src/loaders/index.ts @@ -1,8 +1,8 @@ import pc from 'picocolors'; -import type { Source, UnifiedTokenEvent } from '../types.js'; +import type { Source, UnifiedTokenEvent, CodexRateLimits } from '../types.js'; import { SOURCE_LABELS } from '../types.js'; import { loadClaudeEvents, getClaudeWatchPaths } from './claude.js'; -import { loadCodexEvents, getCodexWatchPaths } from './codex.js'; +import { loadCodexEvents, getCodexWatchPaths, loadCodexRateLimits } from './codex.js'; import { loadGeminiEvents, getGeminiWatchPaths } from './gemini.js'; import { loadOpenCodeEvents, getOpenCodeWatchPaths } from './opencode.js'; import { loadAmpEvents, getAmpWatchPaths } from './amp.js'; @@ -41,6 +41,7 @@ export type LoadAllResult = { events: UnifiedTokenEvent[]; detected: Source[]; errors: Array<{ source: Source; error: string }>; + codexRateLimits: CodexRateLimits | null; }; export async function loadAll(quiet = false): Promise { @@ -49,12 +50,18 @@ export async function loadAll(quiet = false): Promise { const errors: Array<{ source: Source; error: string }> = []; const log = quiet ? () => {} : console.error.bind(console); - const results = await Promise.allSettled( - LOADERS.map(async (loader) => { - const loaderEvents = await loader.load({ quiet }); - return { source: loader.source, events: loaderEvents }; + const [results, codexRateLimits] = await Promise.all([ + Promise.allSettled( + LOADERS.map(async (loader) => { + const loaderEvents = await loader.load({ quiet }); + return { source: loader.source, events: loaderEvents }; + }), + ), + loadCodexRateLimits().catch((err) => { + log(pc.yellow(` warn: codex rate-limits read failed: ${String(err)}`)); + return null; }), - ); + ]); for (const result of results) { if (result.status === 'fulfilled') { @@ -75,5 +82,5 @@ export async function loadAll(quiet = false): Promise { } events.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); - return { events, detected, errors }; + return { events, detected, errors, codexRateLimits }; } diff --git a/src/types.ts b/src/types.ts index 2f6c2e2..85b343f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -137,6 +137,14 @@ export interface DashboardData { bySourceModel: SourceModelAggregation[]; byProject: ProjectAggregation[]; heatmap: HeatmapCell[]; + /** + * Live snapshot of Codex CLI rate-limit state. Null when: + * - Codex isn't installed + * - the user has no session containing a rate_limits-bearing event + * - the user authenticates via OPENAI_API_KEY (planType = null) + * Consumers should treat null as "Codex limits unavailable". + */ + codexRateLimits: CodexRateLimits | null; } export function emptyTokens(): TokenCounts { @@ -182,3 +190,39 @@ export const SOURCE_COLORS: Record = { amp: '#F59E0B', pi: '#8B5CF6', }; + +/** + * Snapshot of Codex CLI rate-limit state read from the most recent + * session JSONL. Codex emits this structure on every `token_count` + * event; we keep only the latest one. Unix-seconds reset times are + * converted to ISO strings at extraction time so consumers can use the + * same Date(...) parsing as Claude's WindowUsage. + */ +export interface CodexWindowUsage { + /** 0-100, matches Claude's WindowUsage.utilization semantics. */ + utilization: number; + /** Window length in minutes (300 for 5h, 10080 for 7d). */ + windowMinutes: number; + /** ISO 8601 timestamp; null only if Codex emitted a malformed entry. */ + resetsAt: string | null; +} + +export interface CodexRateLimits { + /** + * "plus" / "pro" / "team" / "enterprise" / "edu". Null when the + * user authenticates via OPENAI_API_KEY (pay-as-you-go has no plan + * limits — UI should treat null as "Codex toggle unavailable"). + */ + planType: string | null; + /** 5-hour rolling window. Null only if missing in the source event. */ + primary: CodexWindowUsage | null; + /** 7-day rolling window. Null only if missing in the source event. */ + secondary: CodexWindowUsage | null; + /** + * ISO timestamp of the source `token_count` event — i.e. the + * moment of the user's last Codex API call. The widget renders + * these numbers without an "as of" stamp by user request, but we + * expose this for future use / debugging. + */ + snapshotAt: string; +} diff --git a/widget/index.html b/widget/index.html index c392bb1..8001de8 100644 --- a/widget/index.html +++ b/widget/index.html @@ -11,14 +11,32 @@
-
-
- - 5h +
+
+ +
+
+ + 5h +
+
+ + 7d +
+
-
- - 7d +
@@ -35,8 +53,8 @@
-
-
+
+
TokenBBQ
@@ -61,6 +79,11 @@
+ +
diff --git a/widget/src-tauri/capabilities/default.json b/widget/src-tauri/capabilities/default.json index fa8660c..339cf19 100644 --- a/widget/src-tauri/capabilities/default.json +++ b/widget/src-tauri/capabilities/default.json @@ -6,6 +6,8 @@ "core:default", "core:window:default", "core:window:allow-set-size", + "core:window:allow-set-position", + "core:window:allow-available-monitors", "core:window:allow-hide", "core:window:allow-show", "core:window:allow-set-focus", diff --git a/widget/src/main.ts b/widget/src/main.ts index 9c9bc23..813dd1f 100644 --- a/widget/src/main.ts +++ b/widget/src/main.ts @@ -1,13 +1,38 @@ import { invoke } from "@tauri-apps/api/core"; import { isEnabled, enable, disable } from "@tauri-apps/plugin-autostart"; import { listen } from "@tauri-apps/api/event"; -import { getCurrentWindow } from "@tauri-apps/api/window"; +import { availableMonitors, getCurrentWindow, PhysicalPosition } from "@tauri-apps/api/window"; import { getCurrentWebview } from "@tauri-apps/api/webview"; import type { ClaudeUsageResponse, LocalUsageSummary, Settings, SettingsDisplay } from "./types"; -import { renderCompact, renderExpanded, renderError, renderLocalCompact, setViewState } from "./ui"; +import { loadToggleState, saveToggleState, resolveMode, type SourceToggleState } from "./source-toggle"; +import { renderCompact, renderExpanded, renderError, renderLocalCompact, setViewState, getWorkAreaPhysical, currentFrameInsetLogical, clampWindowToWorkAreaOnce, refreshPillPositionIfPillMode, setMonitorWorkAreaPhysical } from "./ui"; const SESSION_KEY_LIFETIME_MS = 28 * 24 * 60 * 60 * 1000; const LOCAL_POLL_INTERVAL_MS = 5 * 60 * 1000; +// Persistent cache of the last successful fetchLocalUsage result. Codex / +// local-AI scanning runs in a sidecar that takes ~2s on startup; without +// the cache the UI shows blank Codex tiles for those 2s every time the +// widget restarts. With it, the previous-session value renders instantly +// and the live result quietly replaces it once the sidecar completes. +const LOCAL_CACHE_KEY = "tokenbbq-local-usage-cache"; + +function loadCachedLocalUsage(): LocalUsageSummary | null { + try { + const raw = localStorage.getItem(LOCAL_CACHE_KEY); + if (!raw) return null; + return JSON.parse(raw) as LocalUsageSummary; + } catch { + return null; + } +} + +function saveCachedLocalUsage(local: LocalUsageSummary): void { + try { + localStorage.setItem(LOCAL_CACHE_KEY, JSON.stringify(local)); + } catch { + // localStorage may be disabled / quota exceeded — non-fatal. + } +} let currentView: "compact" | "expanded" | "settings" = "compact"; let pollTimer: ReturnType | null = null; @@ -15,6 +40,16 @@ let localPollTimer: ReturnType | null = null; let lastUsageJson = ""; let lastLocal: LocalUsageSummary | null = null; +let toggleState: SourceToggleState = loadToggleState(); + +function currentMode() { + const usage = lastUsageJson ? JSON.parse(lastUsageJson) as ClaudeUsageResponse : null; + const hasClaude = !!(usage?.five_hour || usage?.seven_day); + const codex = lastLocal?.codexUsage ?? null; + const hasCodex = !!(codex && codex.planType !== null + && (codex.primary || codex.secondary)); + return resolveMode(toggleState, hasClaude, hasCodex); +} async function fetchUsage(): Promise { try { @@ -22,14 +57,16 @@ async function fetchUsage(): Promise { const json = JSON.stringify(usage); if (json === lastUsageJson) return; lastUsageJson = json; - renderCompact(usage); - renderExpanded(usage, lastLocal); + renderCompact(usage, lastLocal, toggleState); + renderExpanded(usage, lastLocal, toggleState); + // Sync window size to mode — covers the case where dev-mode CSS + // edits change the dual-mode dimensions but the user hasn't + // toggled to trigger a setSize. + if (currentView === "compact") { + setViewState("compact", currentMode()).catch(() => {}); + } } catch (e) { renderError(String(e)); - // Drop the cached payload so the next successful fetch re-renders even - // if claude.ai returns the exact same JSON it did before the error — - // otherwise the UI stays stuck on "err" until the upstream values move. - lastUsageJson = ""; } } @@ -41,36 +78,28 @@ async function fetchLocalUsage(): Promise { try { const local = await invoke("fetch_local_usage"); lastLocal = local; + saveCachedLocalUsage(local); renderLocalCompact(local); - // Re-render the expanded panel only if it's currently mounted; the - // claude.ai poll will pick up `lastLocal` next time it fires anyway. + // Re-render pill + expanded if we already have claude data. Otherwise + // the pill would show stale Codex data (or none) for up to 60s while + // the claude.ai poll catches up — visible especially right after the + // user starts Codex with the Codex toggle already on. if (lastUsageJson) { try { const usage = JSON.parse(lastUsageJson) as ClaudeUsageResponse; - renderExpanded(usage, local); + renderCompact(usage, local, toggleState); + renderExpanded(usage, local, toggleState); + if (currentView === "compact") { + setViewState("compact", currentMode()).catch(() => {}); + } } catch {} } } catch (e) { - // Surface the full error message in the expanded panel so users can see - // why local data is missing without needing DevTools (which prod builds - // don't expose). Production troubleshooting beats clean degradation here. + // Sidecar unavailable / errored → degrade gracefully: hide the local zone, + // keep claude.ai data visible. Console-only so we don't drown the user. console.warn("fetch_local_usage failed:", e); lastLocal = null; renderLocalCompact(null); - const container = document.getElementById("usage-bars"); - if (container) { - const existing = container.querySelector(".local-error") as HTMLElement | null; - const msg = `Local AI tools unavailable.\n\n${String(e)}`; - if (existing) { - existing.textContent = msg; - } else { - const div = document.createElement("div"); - div.className = "local-error"; - div.style.cssText = "margin-top:10px;padding:8px 10px;border:1px solid var(--red);border-radius:6px;color:var(--red);font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word;background:rgba(239,68,68,0.05)"; - div.textContent = msg; - container.appendChild(div); - } - } } } @@ -106,13 +135,23 @@ async function init(): Promise { // Older Tauri versions / unsupported platforms — fall through. } + // Pre-populate from the on-disk cache so Codex / local-AI tiles render + // instantly on startup. The fresh sidecar scan kicks off a moment later + // in startPolling() and overwrites lastLocal once it returns. + const cached = loadCachedLocalUsage(); + if (cached) { + lastLocal = cached; + renderLocalCompact(cached); + } + const settings = await invoke("load_settings"); + await refreshMonitorWorkArea(); if (settings.has_session_key) { // Resync window size to compact in case a Vite hot-reload (or any prior // state desync) left the window at expanded dimensions while the JS // state booted in compact mode. - await setViewState("compact"); + await setViewState("compact", currentMode()); startPolling(); } else { await expand(); @@ -136,16 +175,70 @@ async function init(): Promise { listen("resume-polling", () => startPolling()); setupEventListeners(); setupDragRegions(); + // If a previously-saved pill position is outside the current work area + // (e.g. taskbar moved, display changed), pull it back in once at startup. + // No continuous listener — drag itself clamps inline in setupDragRegions. + clampWindowToWorkAreaOnce().catch(() => {}); +} + +// Onboarding: surface the double-click-to-minimize gesture the first two +// times the user opens the panel, then never again. Counter survives +// across restarts via localStorage. We deliberately don't clear it on +// successful collapse — the user said "show it twice", not "show until +// they figure it out". +const DBLCLICK_HINT_KEY = "tokenbbq-dblclick-hint-count"; +const DBLCLICK_HINT_MAX = 2; +// Wait for the panel's own expand animation to settle before springing the +// hint in — otherwise the two motions overlap and the hint reads as instant. +const DBLCLICK_HINT_DELAY_MS = 450; +const DBLCLICK_HINT_VISIBLE_MS = 3500; +const DBLCLICK_HINT_FADE_MS = 400; +let dblclickHintDelayTimer: ReturnType | null = null; +let dblclickHintShowTimer: ReturnType | null = null; +let dblclickHintHideTimer: ReturnType | null = null; + +function maybeShowDblclickHint(): void { + const seen = parseInt(localStorage.getItem(DBLCLICK_HINT_KEY) ?? "0", 10); + if (Number.isNaN(seen) || seen >= DBLCLICK_HINT_MAX) return; + + const hint = document.getElementById("dblclick-hint"); + if (!hint) return; + + // Cancel any in-flight show/hide from a previous expand so we don't + // double-trigger when the user rapidly toggles compact↔expanded. + if (dblclickHintDelayTimer) clearTimeout(dblclickHintDelayTimer); + if (dblclickHintShowTimer) clearTimeout(dblclickHintShowTimer); + if (dblclickHintHideTimer) clearTimeout(dblclickHintHideTimer); + + localStorage.setItem(DBLCLICK_HINT_KEY, String(seen + 1)); + + dblclickHintDelayTimer = setTimeout(() => { + hint.removeAttribute("hidden"); + // Two rAFs so the browser commits `display: flex` before we add the + // .visible class — without this the transition has nothing to interpolate + // from and the toast snaps in instantly. + requestAnimationFrame(() => requestAnimationFrame(() => { + hint.classList.add("visible"); + })); + + dblclickHintShowTimer = setTimeout(() => { + hint.classList.remove("visible"); + dblclickHintHideTimer = setTimeout(() => { + hint.setAttribute("hidden", ""); + }, DBLCLICK_HINT_FADE_MS); + }, DBLCLICK_HINT_VISIBLE_MS); + }, DBLCLICK_HINT_DELAY_MS); } async function expand(): Promise { currentView = "expanded"; await setViewState("expanded"); + maybeShowDblclickHint(); } async function collapse(): Promise { currentView = "compact"; - await setViewState("compact"); + await setViewState("compact", currentMode()); } async function openSettings(): Promise { @@ -155,12 +248,10 @@ async function openSettings(): Promise { try { const settings = await invoke("load_settings"); const keyInput = document.getElementById("session-key-input") as HTMLInputElement; - // The plaintext key never leaves the OS keyring. Show only that one is - // stored; user enters a new key only if they want to replace it. - keyInput.value = ""; - keyInput.placeholder = settings.has_session_key - ? "(stored — leave empty to keep)" - : "sk-ant-sid02-..."; + if (settings.session_key) + keyInput.value = settings.session_key; + else + keyInput.value = ""; if (settings.org_id) (document.getElementById("org-id-input") as HTMLInputElement).value = settings.org_id; @@ -225,28 +316,175 @@ async function saveSettings(): Promise { // --- Drag & Events --- +// JS-controlled drag. We don't use Tauri's startDragging() because that +// hands the cursor over to Windows' native drag modal — and during that +// modal we can't prevent the window from being dragged past the taskbar +// or off-screen (every async setPosition we make gets immediately +// overridden by the next OS-level mouse update). Doing the drag +// ourselves means we set the position synchronously per pointermove and +// can clamp BEFORE positioning, so the window literally cannot leave the +// work area. +interface DragState { + startScreenX: number; // CSS px, where the cursor was on pointerdown + startScreenY: number; + startWinX: number; // physical px, where the window was on pointerdown + startWinY: number; + winW: number; // physical px, window size (constant during drag) + winH: number; + pointerId: number; + capturer: HTMLElement; + moved: boolean; // crossed the click-vs-drag threshold yet? +} +const DRAG_THRESHOLD_PHYS_PX = 3; +let activeDrag: DragState | null = null; + +async function refreshMonitorWorkArea(): Promise { + try { + const monitors = await availableMonitors(); + if (monitors.length === 0) { + setMonitorWorkAreaPhysical(null); + return; + } + setMonitorWorkAreaPhysical(monitors.reduce((area, monitor) => { + const x = monitor.workArea.position.x; + const y = monitor.workArea.position.y; + const right = x + monitor.workArea.size.width; + const bottom = y + monitor.workArea.size.height; + return { + minX: Math.min(area.minX, x), + minY: Math.min(area.minY, y), + maxX: Math.max(area.maxX, right), + maxY: Math.max(area.maxY, bottom), + }; + }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity })); + } catch (err) { + console.warn('refreshMonitorWorkArea failed:', err); + setMonitorWorkAreaPhysical(null); + } +} + +async function beginJsDrag(e: PointerEvent, capturer: HTMLElement): Promise { + if (e.button !== 0) return; + try { + await refreshMonitorWorkArea(); + const win = getCurrentWindow(); + const pos = await win.outerPosition(); + const size = await win.outerSize(); + activeDrag = { + startScreenX: e.screenX, + startScreenY: e.screenY, + startWinX: pos.x, + startWinY: pos.y, + winW: size.width, + winH: size.height, + pointerId: e.pointerId, + capturer, + moved: false, + }; + capturer.setPointerCapture(e.pointerId); + } catch (err) { + console.warn('beginJsDrag failed:', err); + } +} + +function onJsDragMove(e: PointerEvent): void { + if (!activeDrag || e.pointerId !== activeDrag.pointerId) return; + const dpr = window.devicePixelRatio || 1; + const dxPhys = Math.round((e.screenX - activeDrag.startScreenX) * dpr); + const dyPhys = Math.round((e.screenY - activeDrag.startScreenY) * dpr); + if (!activeDrag.moved) { + if (Math.abs(dxPhys) < DRAG_THRESHOLD_PHYS_PX && Math.abs(dyPhys) < DRAG_THRESHOLD_PHYS_PX) return; + activeDrag.moved = true; + } + let targetX = activeDrag.startWinX + dxPhys; + let targetY = activeDrag.startWinY + dyPhys; + // Clamp BEFORE setPosition so the window never visits an out-of-bounds spot. + // currentFrameInsetLogical() picks the pill's 6-px margin compensation when + // dragging the compact pill, 0 when dragging the expanded panel — keeps the + // *visible* element the same distance from the screen edge in both modes. + const work = getWorkAreaPhysical(currentFrameInsetLogical()); + if (work) { + targetX = Math.max(work.minX, Math.min(targetX, work.maxX - activeDrag.winW)); + targetY = Math.max(work.minY, Math.min(targetY, work.maxY - activeDrag.winH)); + } + // Fire-and-forget: awaiting would make moves stutter. + getCurrentWindow().setPosition(new PhysicalPosition(targetX, targetY)).catch(() => {}); +} + +function endJsDrag(e: PointerEvent): void { + if (!activeDrag || e.pointerId !== activeDrag.pointerId) return; + const moved = activeDrag.moved; + try { + activeDrag.capturer.releasePointerCapture(activeDrag.pointerId); + } catch {} + activeDrag = null; + // If the user actually moved the window (not just clicked), and we ended + // up in pill mode, freshen the pill's home so the next expand/collapse + // cycle anchors to the new spot. No-op in panel mode — panel drags don't + // move the pill's home. + if (moved) { + refreshPillPositionIfPillMode().catch(() => {}); + } +} + function setupDragRegions(): void { const grip = document.getElementById("pill-grip")!; - grip.addEventListener("mousedown", async (e) => { + grip.addEventListener("pointerdown", (e) => { e.stopPropagation(); - await getCurrentWindow().startDragging(); + void beginJsDrag(e, grip); }); - // After a drag, the browser still dispatches a click on the grip (mousedown - // + mouseup on the same element). That click would bubble up to compact-view - // and trigger expand(). Swallow it. + grip.addEventListener("pointermove", onJsDragMove); + grip.addEventListener("pointerup", endJsDrag); + grip.addEventListener("pointercancel", endJsDrag); + // After a drag, the browser still dispatches a click on the grip. That + // click would bubble up to compact-view and trigger expand(). Swallow. grip.addEventListener("click", (e) => e.stopPropagation()); + // Two presses on the same titlebar within 350ms collapse the panel + // instead of starting a drag. (Native dblclick events are unreliable + // when we're capturing the pointer ourselves, so we time mousedown + // intervals manually.) + let lastTitlebarMousedown = 0; + const DBLCLICK_MS = 350; document.querySelectorAll(".titlebar").forEach((el) => { - el.addEventListener("mousedown", async (e) => { + const isExpandedPanelTitlebar = !el.closest("#settings-overlay"); + const tEl = el as HTMLElement; + tEl.addEventListener("pointerdown", (e) => { if ((e.target as HTMLElement).closest("button")) return; - await getCurrentWindow().startDragging(); + if (e.button !== 0) return; + const now = Date.now(); + if ( + isExpandedPanelTitlebar + && currentView === "expanded" + && now - lastTitlebarMousedown < DBLCLICK_MS + ) { + lastTitlebarMousedown = 0; + void collapse(); + return; + } + lastTitlebarMousedown = now; + void beginJsDrag(e, tEl); }); + tEl.addEventListener("pointermove", onJsDragMove); + tEl.addEventListener("pointerup", endJsDrag); + tEl.addEventListener("pointercancel", endJsDrag); }); } function setupEventListeners(): void { document.getElementById("compact-view")!.addEventListener("click", expand); document.getElementById("btn-minimize")!.addEventListener("click", collapse); + + // Double-click anywhere in the expanded panel collapses back to the pill — + // a faster shortcut than aiming for the small minus icon. Interactive + // elements (buttons, inputs, toggles) keep their native double-click + // behavior so e.g. selecting a word in the session-key field still works. + document.getElementById("expanded-view")!.addEventListener("dblclick", (e) => { + if (currentView !== "expanded") return; + const target = e.target as HTMLElement; + if (target.closest("button, input, label, .source-toggle-switch, .field-input-wrap")) return; + void collapse(); + }); document.getElementById("btn-close")!.addEventListener("click", async () => { await getCurrentWindow().hide(); }); @@ -301,6 +539,35 @@ function setupEventListeners(): void { } }); + document.getElementById("usage-bars")!.addEventListener("change", async (e) => { + const target = e.target as HTMLInputElement; + if (target.id === "toggle-claude") toggleState.claude = target.checked; + else if (target.id === "toggle-codex") toggleState.codex = target.checked; + else return; + saveToggleState(toggleState); + + // Snapshot the mode once so renderCompact and setViewState see the + // same value even though the latter is async. If the user fires two + // toggle changes in rapid succession the second handler reads the + // toggleState mutated by the first — accepted for V1 (the worst + // case is one extra render cycle). + const mode = currentMode(); + + // Resize FIRST, then mutate DOM. Otherwise the second row appears + // for one paint frame inside a still-64px window and gets clipped — + // visible flash on Windows/WebView2 when toggling into dual-mode. + if (currentView === "compact") { + await setViewState("compact", mode); + } + if (lastUsageJson) { + try { + const usage = JSON.parse(lastUsageJson) as ClaudeUsageResponse; + renderCompact(usage, lastLocal, toggleState); + renderExpanded(usage, lastLocal, toggleState); + } catch {} + } + }); + // Theme const themeToggle = document.getElementById("theme-toggle") as HTMLInputElement; const savedTheme = localStorage.getItem("tokenbbq-theme") || "dark"; diff --git a/widget/src/source-toggle.ts b/widget/src/source-toggle.ts new file mode 100644 index 0000000..e8d1134 --- /dev/null +++ b/widget/src/source-toggle.ts @@ -0,0 +1,60 @@ +/** + * User preference for which sources the pill should display. + * "claude" — Claude Code Subscription only (default; current behavior) + * "codex" — Codex only + * "both" — stacked dual-mode (pill is taller) + * "none" — both toggles off; pill shows empty placeholder boxes + */ +export type SourceMode = 'claude' | 'codex' | 'both' | 'none'; + +const STORAGE_KEY_CLAUDE = 'tokenbbq-show-claude'; +const STORAGE_KEY_CODEX = 'tokenbbq-show-codex'; + +export interface SourceToggleState { + claude: boolean; + codex: boolean; +} + +/** + * Read the toggle state from localStorage. Defaults: Claude on, Codex + * off — matches legacy single-source behavior so the pill looks + * unchanged for users who don't opt in to Codex. + */ +export function loadToggleState(): SourceToggleState { + const claude = localStorage.getItem(STORAGE_KEY_CLAUDE); + const codex = localStorage.getItem(STORAGE_KEY_CODEX); + return { + claude: claude === null ? true : claude === '1', + codex: codex === '1', + }; +} + +/** Persist the current toggle state. Call after any mutation. */ +export function saveToggleState(state: SourceToggleState): void { + localStorage.setItem(STORAGE_KEY_CLAUDE, state.claude ? '1' : '0'); + localStorage.setItem(STORAGE_KEY_CODEX, state.codex ? '1' : '0'); +} + +/** + * Resolve the effective render mode given user toggles AND data + * availability. + * + * If the user toggled both sources off we honor that explicitly with + * 'none' (empty placeholder pill) rather than silently showing Claude. + * If a source is toggled on but the data hasn't arrived yet we still + * pick its mode so the layout doesn't flicker between empty and full + * during initial load. + */ +export function resolveMode( + state: SourceToggleState, + hasClaudeData: boolean, + hasCodexData: boolean, +): SourceMode { + if (!state.claude && !state.codex) return 'none'; + + const effClaude = state.claude && hasClaudeData; + const effCodex = state.codex && hasCodexData; + if (effClaude && effCodex) return 'both'; + if (effCodex) return 'codex'; + return 'claude'; +} diff --git a/widget/src/styles.css b/widget/src/styles.css index 3e3b6b0..fab6e96 100644 --- a/widget/src/styles.css +++ b/widget/src/styles.css @@ -16,8 +16,8 @@ --green-glow: rgba(34, 197, 94, 0.2); --orange: #E87B35; --orange-glow: rgba(232, 123, 53, 0.2); - --red: #ef4444; - --red-glow: rgba(239, 68, 68, 0.2); + --red: #ec5d5d; + --red-glow: rgba(236, 93, 93, 0.2); } .theme-light { @@ -36,8 +36,8 @@ --green-glow: rgba(22, 163, 74, 0.12); --orange: #d06820; --orange-glow: rgba(208, 104, 32, 0.12); - --red: #dc2626; - --red-glow: rgba(220, 38, 38, 0.12); + --red: #d34040; + --red-glow: rgba(211, 64, 64, 0.12); } @font-face { @@ -71,6 +71,10 @@ body { -webkit-font-smoothing: antialiased; } +body.view-transitioning { + background: transparent; +} + #app { width: 100vw; height: 100vh; @@ -112,6 +116,13 @@ body { .pill:hover::before { opacity: 1; } .pill:active { transform: scale(0.99); } +.pill.dual-mode { + /* Two rows of ~30px chips + 4px gap = 64. Pill 84 leaves 10px + vertical breathing room (5 above, 5 below) — enough not to feel + cramped, not so much that the rows float in empty space. */ + height: 84px; +} + .pill.hidden-pill { opacity: 0; transform: scale(0.95); @@ -153,7 +164,6 @@ body { margin-right: 8px; position: relative; z-index: 1; - filter: drop-shadow(0 0 4px rgba(232, 123, 53, 0.5)); } .pill-provider { @@ -196,6 +206,186 @@ body { letter-spacing: 0.04em; } +/* === Pill rows: stacked container for single + dual mode === + Single-mode renders one .pill-row (no logo); dual-mode renders both + rows with their brand logos visible. */ +.pill-rows { + display: flex; + flex-direction: column; + justify-content: center; + gap: 4px; + flex: 1; + position: relative; + z-index: 1; +} +.pill-row { + display: flex; + align-items: center; + gap: 8px; +} +.pill-row[hidden] { + display: none; +} +.pill-row-logo { + width: 18px; + height: 18px; + display: inline-flex; + color: var(--text-secondary); + flex-shrink: 0; +} +.pill-row-logo svg { + width: 100%; + height: 100%; +} +.pill-row-logo[hidden] { + display: none; +} + +/* In dual-mode the pill is taller so the standalone divider spans + both metric rows. */ +.pill.dual-mode .pill-divider { + height: 60px; +} + +/* In dual-mode the standalone TokenBBQ flame is hidden — each pill-row + shows its own brand logo (Claude/Codex) so users can tell the rows + apart. The window size is unchanged; rows just shift left into the + freed space. */ +.pill.dual-mode .pill-fire { + display: none; +} + +/* Empty state: both source toggles off. Boxes inside .pill-rows show a + centered minus and hide their unit label, but keep the same min-width + they have when populated so the pill layout doesn't visibly shrink. + Scoped to .pill-rows so the today/local zone on the right is untouched. */ +.pill.pill-empty .pill-rows .pill-metric { + justify-content: center; + min-width: 70px; +} +.pill.pill-empty .pill-rows .pill-metric-value { + color: var(--text-tertiary); +} +.pill.pill-empty .pill-rows .pill-metric-label { + display: none; +} + +/* In dual-mode all four chips render at the same width by letting + pill-metrics fill the row and each chip take equal flex share. + Single-mode is untouched — chips stay natural width there. */ +.pill.dual-mode .pill-row .pill-metrics { + flex: 1; +} +.pill.dual-mode .pill-row .pill-metric { + flex: 1; + justify-content: center; +} + +/* === Onboarding hint toast === + Shown briefly the first two times the panel expands, then permanently + stashed via localStorage. Centered in the panel, doesn't intercept + clicks (pointer-events: none) so it never blocks the user. + + Visual approach: frosted-glass pill instead of solid card-color + + accent-border. Depth comes from backdrop blur + a faint top highlight + ("lit from above"), not from a glow. The accent reads as a single + small dot, keeping the chrome quiet. */ +.hint-toast { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0.4); + display: flex; + align-items: center; + gap: 9px; + padding: 7px 14px 7px 12px; + background: rgba(20, 20, 28, 0.62); + backdrop-filter: blur(14px) saturate(1.4); + -webkit-backdrop-filter: blur(14px) saturate(1.4); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 999px; + box-shadow: + 0 10px 28px rgba(0, 0, 0, 0.45), + inset 0 1px 0 rgba(255, 255, 255, 0.09); + font-size: 11px; + font-weight: 500; + color: var(--text-primary); + letter-spacing: 0.015em; + font-feature-settings: "ss01", "cv11"; + white-space: nowrap; + pointer-events: none; + opacity: 0; + z-index: 100; + /* Clip the shimmer sweep to the pill shape. */ + overflow: hidden; + /* Springy "pop out from behind": scale up past 1.0 and settle, with a + gentle opacity ramp so it doesn't read as instant. */ + transition: opacity 0.4s ease-out, + transform 0.55s cubic-bezier(0.34, 1.56, 0.64, 1); +} +/* Edge-trace — a thin accent-colored light filament travels once around + the pill perimeter, hugging the border. The conic-gradient draws a + bright slice rotating around the element's center; the mask-composite + ring clips everything but the 1px border so the visible result is a + spark gliding along the rim. Runs once shortly after pop-in. */ +@property --trace-angle { + syntax: ''; + initial-value: 0deg; + inherits: false; +} +.hint-toast::after { + content: ''; + position: absolute; + inset: 0; + box-sizing: border-box; + padding: 1px; + border-radius: inherit; + background: conic-gradient( + from var(--trace-angle), + transparent 0deg, + transparent 300deg, + rgba(255, 180, 110, 0.5) 335deg, + rgba(255, 235, 200, 0.95) 352deg, + rgba(255, 180, 110, 0.5) 360deg + ); + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask-composite: exclude; + pointer-events: none; + opacity: 0; +} +.hint-toast.visible::after { + animation: hint-edge-trace 1.6s cubic-bezier(0.5, 0, 0.5, 1) 0.3s 1; +} +@keyframes hint-edge-trace { + 0% { --trace-angle: 0deg; opacity: 0; } + 10% { opacity: 1; } + 88% { opacity: 1; } + 100% { --trace-angle: 360deg; opacity: 0; } +} +.hint-toast[hidden] { + display: none; +} +.hint-toast.visible { + opacity: 1; + transform: translate(-50%, -50%) scale(1); +} +.hint-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + flex-shrink: 0; + /* Tiny halo so the dot doesn't read as flat / pasted-on against the + frosted bg, but stays well below "glow" territory. */ + box-shadow: 0 0 6px -1px var(--accent-glow); +} + /* === EXPANDED PANEL === */ .panel { position: absolute; @@ -320,9 +510,22 @@ body { /* Usage row — borderless */ .usage-row { padding: 10px 0; +} + +.usage-body.animate-rows .usage-row { animation: slide-in-row 0.35s cubic-bezier(0.16, 1, 0.3, 1) both; } +.usage-body.silent-refresh .usage-row, +.usage-body.silent-refresh .local-row { + animation: none; +} + +.usage-body.silent-refresh .progress-fill, +.usage-body.silent-refresh .local-row-bar { + transition: none; +} + .usage-row + .usage-row { border-top: 1px solid var(--border-subtle); } @@ -369,7 +572,7 @@ body { .usage-row-reset { font-size: 10px; - color: var(--text-tertiary); + color: var(--text-secondary); font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace; letter-spacing: 0.02em; display: flex; @@ -380,7 +583,7 @@ body { .usage-row-reset svg { width: 10px; height: 10px; - opacity: 0.5; + opacity: 0.7; } /* Progress bar */ @@ -776,41 +979,111 @@ body { border-top: none; } -.local-section-header { - margin-top: 8px; +/* Brand-marked section header (Claude / Codex rate-limit blocks). The title + carries the brand color; the small logo on the left ties it visually to + the matching toggle row above. Slightly more top breathing room than a + plain section header so the two brand blocks read as distinct cards. */ +.section-header.brand { + margin-top: 10px; + padding-top: 8px; border-top: 1px solid var(--border-subtle); - padding-top: 12px; - align-items: flex-start; } -.local-header-titles { +/* Two-column grid: Claude rate windows on the left, Codex on the right. + Used when both sources have plan data, so the user can compare 5h/weekly + utilization at a glance. The vertical divider mirrors the pill's dual-mode + look. Falls back to a single full-width column when only one source has + data (handled in TS by skipping the grid wrapper). */ +.brand-rates-grid { + display: grid; + grid-template-columns: 1fr 1px 1fr; + column-gap: 10px; + margin-top: 4px; +} + +.brand-rates-divider { + background: var(--border-subtle); + align-self: stretch; + margin: 12px 0 4px; +} + +.brand-rates-col { + min-width: 0; +} + +/* Inside the grid we want each column to start cleanly with its brand header. + Reset the .section-header.brand top border + margin since the divider line + between columns already provides the visual separation. */ +.brand-rates-col .section-header.brand { + margin-top: 0; + padding-top: 0; + border-top: none; +} + +/* Tighten rate-limit rows when squeezed into a 140-ish-px column so the + row name + percent still fit on one line and the reset text does not + wrap awkwardly. */ +.brand-rates-col .usage-row { + padding: 6px 0; +} + +.brand-rates-col .usage-row-name { + font-size: 11px; +} + +.brand-rates-col .usage-row-value { + font-size: 12px; +} + +.brand-rates-col .usage-row-reset { + font-size: 10px; +} + +.section-header-title { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.section-header-logo { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.section-header-logo svg { + width: 11px; + height: 11px; + display: block; +} + +.local-header-block { display: flex; flex-direction: column; - gap: 2px; + gap: 4px; + margin-top: 8px; + border-top: 1px solid var(--border-subtle); + padding-top: 12px; } -.local-header-title { - font-size: 10px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-tertiary); +.local-header-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; } -.local-header-date { +.local-header-label { font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace; - font-size: 10px; + font-size: 13px; font-weight: 400; - color: var(--text-tertiary); + color: var(--text-secondary); letter-spacing: 0.02em; - text-transform: none; } -.local-header-stats { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 2px; +.local-header-row-week .local-header-label { + font-size: 11px; + color: var(--text-tertiary); } .local-header-today { @@ -819,7 +1092,6 @@ body { color: var(--accent); font-weight: 600; letter-spacing: 0.01em; - text-transform: none; line-height: 1; } @@ -832,10 +1104,9 @@ body { .local-header-week { font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace; - font-size: 10px; + font-size: 12px; font-weight: 400; color: var(--text-tertiary); - text-transform: none; letter-spacing: 0.02em; } @@ -844,6 +1115,9 @@ body { flex-direction: column; gap: 4px; padding: 6px 0; +} + +.usage-body.animate-rows .local-row { animation: slide-in-row 0.35s cubic-bezier(0.16, 1, 0.3, 1) both; } @@ -945,3 +1219,93 @@ body { width: 11px; height: 11px; } + +/* === Source toggles in expanded panel === + Replaces the old "Claude.ai Subscription" rows. Each row: logo + + label + iOS-style switch. Disabled when no Codex data is available. */ +.source-toggle-list { + display: flex; + flex-direction: column; + gap: 8px; + padding: 4px 0 12px; +} +.source-toggle-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 8px; + background: var(--card-elevated); + border: 1px solid var(--border-subtle); +} +.source-toggle-row.disabled { + opacity: 0.5; +} +.source-toggle-row.disabled .source-toggle-switch { + pointer-events: none; +} +.source-toggle-logo { + width: 18px; + height: 18px; + color: var(--text-secondary); + display: inline-flex; + flex-shrink: 0; +} +.source-toggle-logo svg { + width: 100%; + height: 100%; +} +.source-toggle-label { + flex: 1; + font-size: 13px; + color: var(--text-primary); + display: flex; + flex-direction: column; +} +.source-toggle-hint { + font-size: 10px; + color: var(--text-tertiary); + margin-top: 1px; +} +.source-toggle-switch { + position: relative; + width: 32px; + height: 18px; + cursor: pointer; + flex-shrink: 0; +} +.source-toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} +.source-toggle-slider { + position: absolute; + inset: 0; + background: var(--border); + border-radius: 18px; + transition: background 0.15s; +} +.source-toggle-slider::before { + content: ''; + position: absolute; + width: 14px; + height: 14px; + left: 2px; + top: 2px; + /* Use the foreground color so the thumb stays visible against the + slider track in both themes — hardcoded white was near-invisible + against the light-theme track. */ + background: var(--text-primary); + border-radius: 50%; + transition: transform 0.15s; +} +.source-toggle-switch input:checked + .source-toggle-slider { + background: var(--accent); +} +.source-toggle-switch input:checked + .source-toggle-slider::before { + transform: translateX(14px); +} +.source-toggle-switch input:disabled ~ .source-toggle-slider { + cursor: not-allowed; +} diff --git a/widget/src/types.ts b/widget/src/types.ts index ba0d66a..923578b 100644 --- a/widget/src/types.ts +++ b/widget/src/types.ts @@ -24,25 +24,46 @@ export interface Settings { export interface SettingsDisplay { has_session_key: boolean; + session_key: string | null; org_id: string | null; saved_at: number | null; } export type ViewState = "compact" | "expanded" | "settings"; -/// Mirrors `api_types::SourceSpend` on the Rust side. +/** Mirrors `api_types::SourceSpend` on the Rust side. */ export interface SourceSpend { source: string; tokens: number; } -/// Mirrors `api_types::LocalUsageSummary`. todayDate is null when the store -/// is empty. The widget hides the local zone when the entire summary fails -/// to load, but renders the section header even when todayBySource is []. +/** Mirrors `api_types::CodexWindowUsage`. utilization is 0-100. */ +export interface CodexWindowUsage { + utilization: number; + windowMinutes: number; + resetsAt: string | null; +} + +/** Mirrors `api_types::CodexUsage`. planType is null for API-key auth. */ +export interface CodexUsage { + planType: string | null; + primary: CodexWindowUsage | null; + secondary: CodexWindowUsage | null; + snapshotAt: string; +} + +/** + * Mirrors `api_types::LocalUsageSummary`. todayDate is null when the store + * is empty. codexUsage is null when Codex isn't installed, no plan is + * detected, or the user uses API-key auth. The widget hides the local + * zone when the entire summary fails to load, but renders the section + * header even when todayBySource is []. + */ export interface LocalUsageSummary { generated: string; todayDate: string | null; todayTokens: number; weekTokens: number; todayBySource: SourceSpend[]; + codexUsage: CodexUsage | null; } diff --git a/widget/src/ui.ts b/widget/src/ui.ts index ff2b642..7aa1c73 100644 --- a/widget/src/ui.ts +++ b/widget/src/ui.ts @@ -1,10 +1,19 @@ -import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window"; +import { getCurrentWindow, LogicalSize, PhysicalPosition } from "@tauri-apps/api/window"; import type { ClaudeUsageResponse, LocalUsageSummary, ViewState } from "./types"; +import { resolveMode, type SourceMode, type SourceToggleState } from "./source-toggle"; -const COMPACT_SIZE = { width: 320, height: 64 }; -const EXPANDED_WIDTH = 320; -// Initial guess; the real height is recomputed from content via fitExpandedToContent. -const EXPANDED_INITIAL_HEIGHT = 320; +const COMPACT_SIZE_SINGLE = { width: 320, height: 64 }; +// Identical width to single — only the height grows for the second +// chip row. Pill 84 tall + 12 margin + 2 WebView2 buffer = 98. +const COMPACT_SIZE_DUAL = { width: 320, height: 98 }; + +function compactSizeForMode(mode: SourceMode): { width: number; height: number } { + return mode === 'both' ? COMPACT_SIZE_DUAL : COMPACT_SIZE_SINGLE; +} +// Expanded panel widens beyond the pill so the side-by-side Claude/Codex +// rate-limit grid has breathing room (the pill stays narrow because it +// lives on screen all the time). +const EXPANDED_WIDTH = 440; // Hard ceiling so a runaway list never grows past a usable widget — content // scrolls inside the panel instead. const EXPANDED_MAX_HEIGHT = 720; @@ -61,6 +70,8 @@ export function utilizationColor(pct: number): string { return `var(--${colorTier(pct)})`; } +// Long-form reset labels for the expanded panel. "Resets in 2h 15m" or +// "Resets May 5". Empty string when no timestamp is available yet. function formatTimeUntil(isoString: string | null): string { if (!isoString) return ""; const diffMs = new Date(isoString).getTime() - Date.now(); @@ -99,52 +110,220 @@ function formatDaysCompact(isoString: string | null): string { return `${Math.ceil(diffMs / 86400000)}d`; } +// Monochrome brand marks — small (14-18 px slot), tint via currentColor +// so they pick up the foreground in both dark and light themes. +// Claude: 8-petal radial burst (Anthropic's brand language — softer than a +// plain star, with rounded "tentacle" ends). +// Codex/OpenAI: simplified knot/loop motif (OpenAI's brand language). +// Simplified silhouettes, not pixel-exact replicas of the official marks. +// const claudeBadgeSvg = ``; +// const codexBadgeSvg = ``; +const claudeBadgeSvg = ``; +const codexBadgeSvg = ``; + +const CLAUDE_BRAND_COLOR = '#c15f3c'; +const CODEX_BRAND_COLOR = '#74aa9c'; + const clockSvg = ``; +// Single utilization row for the expanded panel: name + percent on top, +// progress bar below, "Resets in ..." line beneath. `resetText` is the +// already-formatted string (different windows want different formats). +// +// When `brandColor` is set the entire row (dot, percent, bar) renders in +// that brand color — used for the Claude / Codex rate-limit blocks where +// the bar fill width already conveys utilization, so we drop the +// green/orange/red tier signal in favor of brand identity. Without +// `brandColor` we fall back to the tier coloring (still used by Extra +// Usage and any other utilization row added later). function usageRowHtml( name: string, - usage: { utilization: number; resets_at: string | null } | null, + pct: number, + resetText: string, + brandColor?: string, ): string { - if (!usage) return ""; - const pct = usage.utilization; - const tier = colorTier(pct); - const color = `var(--${tier})`; - const glow = `var(--${tier}-glow)`; - const resetText = name === "5-Hour Window" - ? formatTimeUntil(usage.resets_at) - : formatResetDate(usage.resets_at); - + let color: string; + let glow: string; + let fillStyle: string; + if (brandColor) { + color = brandColor; + // Soft halo around the dot + bar in the same hue. 33 = ~20% alpha. + glow = `${brandColor}66`; + fillStyle = `width:${pct}%;background:${brandColor};box-shadow:0 0 8px ${brandColor}55`; + } else { + const tier = colorTier(pct); + color = `var(--${tier})`; + glow = `var(--${tier}-glow)`; + fillStyle = `width:${pct}%`; + } + const fillClass = brandColor ? 'progress-fill' : `progress-fill ${colorTier(pct)}`; return `
${name} - ${Math.round(pct)}% + ${Math.round(pct)}%
-
+
${resetText ? `
${clockSvg}${resetText}
` : ""}
`; } -export function renderCompact(usage: ClaudeUsageResponse): void { - const fiveHour = document.getElementById("five-hour-compact")!; - const sevenDay = document.getElementById("seven-day-compact")!; +// Section-header with a small brand logo + colored title. Used to mark the +// Claude / Codex rate-limit sections so they visually pair with the toggle +// rows above (same logos, same brand colors). +function brandSectionHeaderHtml(label: string, logoSvg: string, color: string): string { + return ` +
+ + ${label} + +
`; +} + +// Map a Codex window's length (minutes) to a human label parallel to the +// Claude side ("5-Hour Window" / "Weekly Window"). Falls back to a generic +// hour/day form for unusual window sizes. +function codexWindowLabel(windowMinutes: number): string { + if (!Number.isFinite(windowMinutes) || windowMinutes <= 0) return "Window"; + if (windowMinutes < 1440) { + const hours = Math.round(windowMinutes / 60); + return `${hours}-Hour Window`; + } + const days = Math.round(windowMinutes / 1440); + if (days === 7) return "Weekly Window"; + return `${days}-Day Window`; +} + +function toggleRowHtml( + id: string, + label: string, + logoSvg: string, + logoColor: string, + checked: boolean, + disabled: boolean, + hint?: string, +): string { + return ` +
+ + ${label}${hint ? `${hint}` : ''} + +
`; +} + +export function renderCompact( + usage: ClaudeUsageResponse, + local: LocalUsageSummary | null, + toggleState: SourceToggleState, +): void { + const codex = local?.codexUsage ?? null; + const hasClaude = !!(usage.five_hour || usage.seven_day); + const hasCodex = codex !== null && codex.planType !== null + && (codex.primary !== null || codex.secondary !== null); + const mode = resolveMode(toggleState, hasClaude, hasCodex); + + const fiveHour = document.getElementById("five-hour-compact")! as HTMLElement; + const sevenDay = document.getElementById("seven-day-compact")! as HTMLElement; const fiveHourLabel = document.getElementById("five-hour-label")!; const sevenDayLabel = document.getElementById("seven-day-label")!; + const primaryLogo = document.getElementById('pill-row-logo-primary')!; + const secondaryLogo = document.getElementById('pill-row-logo-secondary')!; + + // Toggle the empty-state class up front. CSS handles the styling + // (centered dash, hidden label, muted color) so we don't have to + // reach for these visual details in every branch below. + document.getElementById('compact-view')!.classList.toggle('pill-empty', mode === 'none'); + + if (mode === 'none') { + // User has both Claude and Codex toggled off. Show two empty boxes + // with a centered minus — no fallback numbers, no labels. + setSingleRowVisibility(); + fiveHour.textContent = '−'; // U+2212 MINUS SIGN + fiveHour.style.color = ''; + sevenDay.textContent = '−'; + sevenDay.style.color = ''; + fiveHourLabel.textContent = ''; + sevenDayLabel.textContent = ''; + return; + } + + // Helper: reset to single-row layout (hide secondary row + both row logos). + // In single-mode the standalone .pill-fire on the left identifies the source. + // In dual-mode the flame is hidden via CSS and each row carries its own brand + // logo so users can tell Claude (top) from Codex (bottom) at a glance. + function setSingleRowVisibility(): void { + document.getElementById('pill-row-secondary')!.setAttribute('hidden', ''); + primaryLogo.setAttribute('hidden', ''); + primaryLogo.innerHTML = ''; + secondaryLogo.setAttribute('hidden', ''); + secondaryLogo.innerHTML = ''; + } + + if (mode === 'both' && codex) { + // Dual-mode: show both rows AND their brand logos. CSS hides the + // standalone .pill-fire in this mode, so the row logos are the only + // brand identifier visible. + document.getElementById('pill-row-secondary')!.removeAttribute('hidden'); + primaryLogo.innerHTML = claudeBadgeSvg; + primaryLogo.style.color = CLAUDE_BRAND_COLOR; + primaryLogo.removeAttribute('hidden'); + secondaryLogo.innerHTML = codexBadgeSvg; + secondaryLogo.style.color = CODEX_BRAND_COLOR; + secondaryLogo.removeAttribute('hidden'); + + // Primary row = Claude + const fhPctC = usage.five_hour?.utilization ?? 0; + const sdPctC = usage.seven_day?.utilization ?? 0; + fiveHour.textContent = `${Math.round(fhPctC)}%`; + fiveHour.style.color = utilizationColor(fhPctC); + sevenDay.textContent = `${Math.round(sdPctC)}%`; + sevenDay.style.color = utilizationColor(sdPctC); + fiveHourLabel.textContent = formatHoursCompact(usage.five_hour?.resets_at ?? null) || "5h"; + sevenDayLabel.textContent = formatDaysCompact(usage.seven_day?.resets_at ?? null) || "7d"; + + // Secondary row = Codex + const fhPctX = codex.primary?.utilization ?? 0; + const sdPctX = codex.secondary?.utilization ?? 0; + const fh2 = document.getElementById('five-hour-compact-2')! as HTMLElement; + const sd2 = document.getElementById('seven-day-compact-2')! as HTMLElement; + const fh2l = document.getElementById('five-hour-label-2')!; + const sd2l = document.getElementById('seven-day-label-2')!; + fh2.textContent = `${Math.round(fhPctX)}%`; + fh2.style.color = utilizationColor(fhPctX); + sd2.textContent = `${Math.round(sdPctX)}%`; + sd2.style.color = utilizationColor(sdPctX); + fh2l.textContent = formatHoursCompact(codex.primary?.resetsAt ?? null) || "5h"; + sd2l.textContent = formatDaysCompact(codex.secondary?.resetsAt ?? null) || "7d"; + return; + } + + if (mode === 'codex' && codex) { + setSingleRowVisibility(); + const fhPct = codex.primary?.utilization ?? 0; + const sdPct = codex.secondary?.utilization ?? 0; + fiveHour.textContent = `${Math.round(fhPct)}%`; + fiveHour.style.color = utilizationColor(fhPct); + sevenDay.textContent = `${Math.round(sdPct)}%`; + sevenDay.style.color = utilizationColor(sdPct); + fiveHourLabel.textContent = formatHoursCompact(codex.primary?.resetsAt ?? null) || "5h"; + sevenDayLabel.textContent = formatDaysCompact(codex.secondary?.resetsAt ?? null) || "7d"; + return; + } + + // Default (claude single layout). + setSingleRowVisibility(); const fhPct = usage.five_hour?.utilization ?? 0; const sdPct = usage.seven_day?.utilization ?? 0; - fiveHour.textContent = `${Math.round(fhPct)}%`; fiveHour.style.color = utilizationColor(fhPct); sevenDay.textContent = `${Math.round(sdPct)}%`; sevenDay.style.color = utilizationColor(sdPct); - - // Time-until-reset replaces the static window-length labels. Fall back to - // the original "5h"/"7d" strings if resets_at is missing (initial load). - const fhRemaining = formatHoursCompact(usage.five_hour?.resets_at ?? null); - const sdRemaining = formatDaysCompact(usage.seven_day?.resets_at ?? null); - fiveHourLabel.textContent = fhRemaining || "5h"; - sevenDayLabel.textContent = sdRemaining || "7d"; + fiveHourLabel.textContent = formatHoursCompact(usage.five_hour?.resets_at ?? null) || "5h"; + sevenDayLabel.textContent = formatDaysCompact(usage.seven_day?.resets_at ?? null) || "7d"; } /// Render the local-AI-tools half of the compact pill. Pass null to hide the @@ -152,7 +331,7 @@ export function renderCompact(usage: ClaudeUsageResponse): void { export function renderLocalCompact(local: LocalUsageSummary | null): void { const divider = document.getElementById("pill-divider")!; const local_zone = document.getElementById("pill-local")!; - const today = document.getElementById("today-compact")!; + const today = document.getElementById("today-compact")! as HTMLElement; if (!local) { divider.hidden = true; @@ -163,17 +342,109 @@ export function renderLocalCompact(local: LocalUsageSummary | null): void { divider.hidden = false; local_zone.hidden = false; today.textContent = formatTokens(local.todayTokens); + + // Tint the today value with the brand color of whichever local AI tool + // contributed the most tokens today — gives an at-a-glance read of the + // dominant source. Falls back to the CSS default (--accent) when there's + // no per-source breakdown yet. + if (local.todayBySource.length > 0) { + const top = local.todayBySource.reduce((a, b) => (b.tokens > a.tokens ? b : a)); + today.style.color = sourceColor(top.source); + } else { + today.style.color = ''; + } +} + +// Claude rate-limit block: header + 5h + 7d windows. Returns an empty +// string when no rate-limit data is present so callers can decide whether +// to render at all. Used both stand-alone (full-width) and inside the +// two-column grid. +function renderClaudeRatesHtml(usage: ClaudeUsageResponse): string { + if (!usage.five_hour && !usage.seven_day) return ''; + let out = brandSectionHeaderHtml('Claude Code', claudeBadgeSvg, CLAUDE_BRAND_COLOR); + if (usage.five_hour) { + out += usageRowHtml( + '5-Hour Window', + usage.five_hour.utilization, + formatTimeUntil(usage.five_hour.resets_at), + CLAUDE_BRAND_COLOR, + ); + } + if (usage.seven_day) { + out += usageRowHtml( + 'Weekly Window', + usage.seven_day.utilization, + formatResetDate(usage.seven_day.resets_at), + CLAUDE_BRAND_COLOR, + ); + } + return out; +} + +// Codex rate-limit block: parallel structure to the Claude block above. The +// caller already gates on plan availability — this function trusts that and +// just renders whatever windows exist. +function renderCodexRatesHtml(codex: NonNullable): string { + if (!codex.primary && !codex.secondary) return ''; + let out = brandSectionHeaderHtml('Codex', codexBadgeSvg, CODEX_BRAND_COLOR); + if (codex.primary) { + out += usageRowHtml( + codexWindowLabel(codex.primary.windowMinutes), + codex.primary.utilization, + formatTimeUntil(codex.primary.resetsAt), + CODEX_BRAND_COLOR, + ); + } + if (codex.secondary) { + out += usageRowHtml( + codexWindowLabel(codex.secondary.windowMinutes), + codex.secondary.utilization, + formatResetDate(codex.secondary.resetsAt), + CODEX_BRAND_COLOR, + ); + } + return out; } export function renderExpanded( usage: ClaudeUsageResponse, local: LocalUsageSummary | null = null, + toggleState: SourceToggleState = { claude: true, codex: false }, ): void { const container = document.getElementById("usage-bars")!; - - let html = `
Claude.ai Subscription
`; - html += usageRowHtml("5-Hour Window", usage.five_hour); - html += usageRowHtml("7-Day Window", usage.seven_day); + const panel = document.getElementById("expanded-view")!; + const isLiveRefresh = panel.classList.contains("visible") && container.childElementCount > 0; + + const codex = local?.codexUsage ?? null; + const codexAvailable = codex !== null && codex.planType !== null; + const codexHint = codex === null + ? '(no data)' + : (codex.planType === null ? '(API key — no plan)' : ''); + + let html = `
Pill displays
`; + html += `
`; + html += toggleRowHtml('toggle-claude', 'Claude Code', claudeBadgeSvg, CLAUDE_BRAND_COLOR, toggleState.claude, false); + html += toggleRowHtml('toggle-codex', 'Codex', codexBadgeSvg, CODEX_BRAND_COLOR, toggleState.codex && codexAvailable, !codexAvailable, codexHint); + html += `
`; + + // Claude + Codex rate-limit blocks. Side-by-side when both have data; + // single full-width column when only one is present. Hidden completely + // when neither has data. + const claudeBlock = renderClaudeRatesHtml(usage); + const codexBlock = codexAvailable && codex ? renderCodexRatesHtml(codex) : ''; + + if (claudeBlock && codexBlock) { + html += ` +
+
${claudeBlock}
+
+
${codexBlock}
+
`; + } else if (claudeBlock) { + html += claudeBlock; + } else if (codexBlock) { + html += codexBlock; + } if (usage.extra_usage && usage.extra_usage.is_enabled) { const ex = usage.extra_usage; @@ -200,14 +471,20 @@ export function renderExpanded( html += renderLocalExpandedHtml(local); } + container.classList.toggle("animate-rows", !isLiveRefresh); + container.classList.toggle("silent-refresh", isLiveRefresh); container.innerHTML = html; - // Resize the host window to fit whatever we just rendered. Two rAFs because - // the first one fires before the browser has reflowed the new innerHTML; - // measuring then would still see the old layout. - if (document.getElementById("expanded-view")!.classList.contains("visible")) { - requestAnimationFrame(() => requestAnimationFrame(() => { void fitExpandedToContent(); })); - } + // Intentionally do NOT auto-resize the window when new data arrives while + // the panel is open. Two reasons: + // 1. setPosition would yank the window back to the captured pill anchor + // every poll, even if the user has it placed comfortably — that's + // extremely user-hostile. + // 2. Any size feedback loop (e.g. a flex:1 body whose scrollHeight tracks + // the window) would silently grow the window until it hits MAX_HEIGHT. + // The window is sized exactly once on expand (via measureExpandedHeight) + // and stays that size. If content outgrows it, the body's overflow-y:auto + // takes over and scrolls inside the fixed window. } function renderLocalExpandedHtml(local: LocalUsageSummary): string { @@ -240,14 +517,14 @@ function renderLocalExpandedHtml(local: LocalUsageSummary): string { } return ` -
-
- Local AI Tools - ${escapeHtml(dateLabel)} -
-
+
+
+ ${escapeHtml(dateLabel)} ${formatTokens(local.todayTokens)} tok - ${formatTokens(local.weekTokens)} · 7d +
+
+ 7d + ${formatTokens(local.weekTokens)}
${inner} @@ -291,55 +568,299 @@ export function renderError(message: string): void {
`; } -export async function setViewState(state: ViewState): Promise { +// pillPosition is the pill's "home" — the visible top-left of the compact +// pill in physical px. Stored in *visible* coordinates (not tauri-window +// coordinates) so it's mode-independent: pill has a 6-px CSS margin while +// panel uses inset:0, so the same tauri-x means different visible-x in the +// two modes; storing the visible-x sidesteps that mismatch. +// +// Updated only when in PILL mode (pill drag-end, or first capture). Panel +// drags deliberately do NOT update — the pill's home stays put while the +// user moves the panel around, so collapsing always returns to the same +// pill spot. +interface PillPosition { + visibleX: number; // physical px + visibleY: number; +} +let pillPosition: PillPosition | null = null; + +// Read the current pill's visible top-left. Caller must be in pill mode +// (i.e. compact-view does NOT have hidden-pill); we don't double-check +// because every call site is gated. +async function captureCurrentPillPosition(): Promise { + try { + const win = getCurrentWindow(); + const pos = await win.outerPosition(); + const scale = await win.scaleFactor(); + const insetPhys = Math.round(PILL_FRAME_INSET_LOGICAL * scale); + pillPosition = { + visibleX: pos.x + insetPhys, + visibleY: pos.y + insetPhys, + }; + } catch (e) { + console.warn('captureCurrentPillPosition failed:', e); + } +} + +// Hook for main.ts: refresh pillPosition after a drag-end, but only if +// currently in pill mode. Panel drags are no-ops here. +export async function refreshPillPositionIfPillMode(): Promise { + if (currentFrameInsetLogical() === PILL_FRAME_INSET_LOGICAL) { + await captureCurrentPillPosition(); + } +} + +// Position the panel so its visible top-left equals pillPosition. Panel +// grows down-right naturally; if it would overflow the work area, the +// clamp shifts it up-left so it stays fully visible. Anchor is top-left, +// not bottom-right, so the user perceives the panel as "opening downward +// from the pill" the way a normal Windows app does. +async function positionPanelAtPill(): Promise { + if (!pillPosition) return; + try { + const win = getCurrentWindow(); + const size = await win.outerSize(); + // Panel's CSS inset is 0, so panel tauri-x equals visible-x. + let targetX = pillPosition.visibleX; + let targetY = pillPosition.visibleY; + const work = getWorkAreaPhysical(0); + if (work) { + targetX = Math.max(work.minX, Math.min(targetX, work.maxX - size.width)); + targetY = Math.max(work.minY, Math.min(targetY, work.maxY - size.height)); + } + await win.setPosition(new PhysicalPosition(targetX, targetY)); + } catch (e) { + console.warn('positionPanelAtPill failed:', e); + } +} + +// Restore the pill to its home. Pill's CSS inset is 6, so tauri-x = +// visible-x - 6 (logical, scaled to physical). +async function restorePillToHome(): Promise { + if (!pillPosition) return; + try { + const win = getCurrentWindow(); + const size = await win.outerSize(); + const scale = await win.scaleFactor(); + const insetPhys = Math.round(PILL_FRAME_INSET_LOGICAL * scale); + let targetX = pillPosition.visibleX - insetPhys; + let targetY = pillPosition.visibleY - insetPhys; + const work = getWorkAreaPhysical(PILL_FRAME_INSET_LOGICAL); + if (work) { + targetX = Math.max(work.minX, Math.min(targetX, work.maxX - size.width)); + targetY = Math.max(work.minY, Math.min(targetY, work.maxY - size.height)); + } + await win.setPosition(new PhysicalPosition(targetX, targetY)); + } catch (e) { + console.warn('restorePillToHome failed:', e); + } +} + +// Physical-px breathing room kept between the *visible element* and every +// work-area edge. This intentionally does not scale with Windows DPI: +// "8 px from the wall" should look like 8 actual screen pixels, not +// 10-12 px on 125-150% scaling. +const EDGE_MARGIN_PHYSICAL = 8; + +// Compact pill has a 6-px outer margin in CSS (`.pill { margin: 6px }`) +// to give the hover glow room before WebView2's overflow:hidden clips it. +// Expanded panel uses `inset: 0` and fills its window edge-to-edge. The +// Tauri window itself is the same in both modes, so when we clamp the +// window position with EDGE_MARGIN we end up with a visible pill that +// sits 6 px farther from the screen edge than the visible panel. +// Callers compensate by passing this inset to getWorkAreaPhysical when +// the active view is the compact pill. +export const PILL_FRAME_INSET_LOGICAL = 6; + +// Returns true when the compact pill (not the expanded panel) is the +// currently visible surface. The .hidden-pill class is added in +// setViewState whenever we transition to the expanded/settings view, so +// its absence means the pill is the visible element. +export function currentFrameInsetLogical(): number { + const compact = document.getElementById('compact-view'); + if (compact && !compact.classList.contains('hidden-pill')) { + return PILL_FRAME_INSET_LOGICAL; + } + return 0; +} + +type WorkAreaPhysical = { minX: number; minY: number; maxX: number; maxY: number }; +let monitorWorkAreaPhysical: WorkAreaPhysical | null = null; + +export function setMonitorWorkAreaPhysical(workArea: WorkAreaPhysical | null): void { + monitorWorkAreaPhysical = workArea; +} + +// Work-area bounds in physical pixels: the screen area NOT covered by the +// Windows taskbar, inset by (EDGE_MARGIN_PHYSICAL - frameInsetPhysical) on +// every side. Prefer the Tauri monitor union so dragging can cross monitors; +// fall back to Chromium's current-screen work area if the monitor API is not +// available yet during early startup. +export function getWorkAreaPhysical( + frameInsetLogical = 0, +): WorkAreaPhysical | null { + const dpr = window.devicePixelRatio; + if (!dpr || !Number.isFinite(dpr)) return null; + const frameInsetPhysical = Math.round(frameInsetLogical * dpr); + const margin = Math.max(0, EDGE_MARGIN_PHYSICAL - frameInsetPhysical); + if (monitorWorkAreaPhysical) { + return { + minX: monitorWorkAreaPhysical.minX + margin, + minY: monitorWorkAreaPhysical.minY + margin, + maxX: monitorWorkAreaPhysical.maxX - margin, + maxY: monitorWorkAreaPhysical.maxY - margin, + }; + } + // availLeft / availTop are non-standard but supported in Chromium-based + // WebView2; TS's lib.dom doesn't declare them, hence the cast. + const screenAny = window.screen as unknown as { availLeft?: number; availTop?: number }; + const left = (screenAny.availLeft ?? 0) * dpr; + const top = (screenAny.availTop ?? 0) * dpr; + const width = window.screen.availWidth * dpr; + const height = window.screen.availHeight * dpr; + return { + minX: Math.round(left) + margin, + minY: Math.round(top) + margin, + maxX: Math.round(left + width) - margin, + maxY: Math.round(top + height) - margin, + }; +} + +// (Old positionExpandedWindow / pillAnchor removed — replaced by the +// captureVisibleAnchor / applyVisibleAnchor pair above, which is mode-aware +// and handles both expand and collapse transitions consistently.) + +// Measure how tall the expanded panel needs to be at EXPANDED_WIDTH, *without* +// resizing the actual window. We clone the live panel into an off-screen +// container at the target width, force a layout pass, read titlebar height + +// usage-body scrollHeight, then dispose. The clone inherits the user's +// rendered content, so the measurement reflects whatever data is currently +// shown — no special seeding needed. +// +// This is the secret sauce that makes the expand transition smooth: instead +// of resizing twice (initial floor → final content fit, with a visible jump +// in between), we resize ONCE to the right height before revealing the +// panel. +// Measure how tall the expanded panel needs to be at EXPANDED_WIDTH without +// touching the live window. We build a minimal off-screen measurement +// container at the target width that mirrors the live structure (titlebar +// + usage-body content) but with NO flex / NO overflow constraints, so +// children stack to their natural sizes. The result is the exact height +// needed to display all content without scrolling. +// +// Why not clone the whole .panel element: the panel's own CSS uses +// `position: absolute; inset: 0; display: flex; flex-direction: column` +// plus a `flex: 1` body. Cloning that into an auto-sized container creates +// phantom height (flex:1 children behave oddly without a sized parent) and +// settings-overlay's `inset: 0` muddies the measurement. Building from +// scratch is simpler and predictable. +function measureExpandedHeight(panel: HTMLElement, targetWidth: number): number { + const titlebar = panel.querySelector('.titlebar') as HTMLElement | null; + const body = panel.querySelector('.usage-body') as HTMLElement | null; + if (!titlebar || !body) return EXPANDED_MIN_HEIGHT; + + const measure = document.createElement('div'); + measure.style.cssText = [ + 'position: absolute', + 'top: -10000px', + 'left: 0', + `width: ${targetWidth}px`, + 'visibility: hidden', + 'pointer-events: none', + 'border: 1px solid transparent', // matches .panel's 1px border so width math is identical + 'border-radius: 16px', + ].join(';'); + + // Clone the titlebar — it carries its own CSS classes, layout is identical. + const titlebarClone = titlebar.cloneNode(true) as HTMLElement; + measure.appendChild(titlebarClone); + + // Recreate the usage-body but force natural-flow layout so children stack + // to their real heights instead of fighting flex:1. + const bodyMeasure = document.createElement('div'); + bodyMeasure.className = 'usage-body'; + bodyMeasure.style.cssText = 'flex: 0 0 auto; overflow: visible; height: auto; max-height: none'; + bodyMeasure.innerHTML = body.innerHTML; + measure.appendChild(bodyMeasure); + + document.body.appendChild(measure); + void measure.offsetHeight; // force synchronous layout + const total = measure.offsetHeight; + document.body.removeChild(measure); + + return Math.min(EXPANDED_MAX_HEIGHT, Math.max(EXPANDED_MIN_HEIGHT, total)); +} + +export async function setViewState(state: ViewState, mode: SourceMode = 'claude'): Promise { const pill = document.getElementById("compact-view")!; const panel = document.getElementById("expanded-view")!; const settings = document.getElementById("settings-overlay")!; const win = getCurrentWindow(); if (state === "compact") { + // Restore the pill to its home. Note we deliberately do NOT capture + // the current panel position — the pill's home is a separate concept + // that only updates from pill drags, so the user can move the panel + // around mid-expand and the pill still returns to where it was. + document.body.classList.remove("view-transitioning"); settings.classList.remove("visible"); panel.classList.remove("visible"); pill.classList.remove("hidden-pill"); - await win.setSize(new LogicalSize(COMPACT_SIZE.width, COMPACT_SIZE.height)); + pill.classList.toggle("dual-mode", mode === 'both'); + const sz = compactSizeForMode(mode); + await win.setSize(new LogicalSize(sz.width, sz.height)); + await restorePillToHome(); } else if (state === "expanded") { + // Capture the pill's home on first expand. Subsequent expands (after + // the pill has been dragged) keep the position fresh via main.ts's + // drag-end hook (refreshPillPositionIfPillMode). + if (!pillPosition) await captureCurrentPillPosition(); settings.classList.remove("visible"); - pill.classList.add("hidden-pill"); - // Set an initial floor so the panel has space to lay out before we measure. - // fitExpandedToContent then snaps the window to the actual content height. - await win.setSize(new LogicalSize(EXPANDED_WIDTH, EXPANDED_INITIAL_HEIGHT)); + panel.classList.remove("visible"); + document.body.classList.add("view-transitioning"); + // Pre-measure the panel content at the target width using an off-screen + // clone, so we can resize the window once to its final size instead of + // doing a two-stage initial-then-fit dance that the user perceives as + // jitter. + const targetH = measureExpandedHeight(panel, EXPANDED_WIDTH); + await win.setSize(new LogicalSize(EXPANDED_WIDTH, targetH)); + await positionPanelAtPill(); + // Window is now at final size + position. Swap pill -> panel in the next + // paint so the user never sees an empty resized body between states. requestAnimationFrame(() => { + pill.classList.add("hidden-pill"); panel.classList.add("visible"); - requestAnimationFrame(() => { void fitExpandedToContent(); }); + document.body.classList.remove("view-transitioning"); }); } else if (state === "settings") { + document.body.classList.remove("view-transitioning"); settings.classList.add("visible"); } } -/// Snap the host window to fit the expanded panel's actual content height, -/// clamped to [MIN, MAX]. Beyond MAX the panel scrolls instead. Called on -/// view transitions and after every renderExpanded so the window grows / -/// shrinks as data arrives (e.g. local AI tools section appearing late). -/// -/// We measure titlebar + usage-body.scrollHeight rather than panel.scrollHeight -/// because the body has overflow:auto — when the window is currently smaller -/// than the content, the panel's own scrollHeight reports the truncated -/// (clipped) value, not what we'd need to fit everything. -export async function fitExpandedToContent(): Promise { - const panel = document.getElementById("expanded-view"); - if (!panel || !panel.classList.contains("visible")) return; - const titlebar = panel.querySelector(".titlebar") as HTMLElement | null; - const body = panel.querySelector(".usage-body") as HTMLElement | null; - const titleH = titlebar?.offsetHeight ?? 0; - const bodyH = body?.scrollHeight ?? panel.scrollHeight; - // +8px absorbs sub-pixel rounding under fractional DPR and leaves a small - // breathing margin under the last row so the panel doesn't end flush with - // the window edge. - const target = Math.min(EXPANDED_MAX_HEIGHT, Math.max(EXPANDED_MIN_HEIGHT, titleH + bodyH + 8)); +// One-shot work-area clamp at startup: if a previously-saved pill position +// is now outside the work area (e.g. the user changed their taskbar height +// or display setup since last run), nudge the window back inside. The +// regular drag is clamped inline in setupDragRegions (JS drag), so we +// don't need a continuous listener fighting Windows' move events. +export async function clampWindowToWorkAreaOnce(): Promise { try { - await getCurrentWindow().setSize(new LogicalSize(EXPANDED_WIDTH, target)); - } catch { - // Window may have been hidden between rAF and resize; nothing useful to do. + const win = getCurrentWindow(); + // Startup is always compact-mode, so use the pill's frame inset so + // the visible pill ends up exactly EDGE_MARGIN_PHYSICAL from the edge. + const work = getWorkAreaPhysical(currentFrameInsetLogical()); + if (!work) return; + const pos = await win.outerPosition(); + const size = await win.outerSize(); + const maxX = work.maxX - size.width; + const maxY = work.maxY - size.height; + const clampedX = Math.max(work.minX, Math.min(pos.x, maxX)); + const clampedY = Math.max(work.minY, Math.min(pos.y, maxY)); + if (clampedX !== pos.x || clampedY !== pos.y) { + await win.setPosition(new PhysicalPosition(clampedX, clampedY)); + } + } catch (e) { + console.warn('startup work-area clamp failed:', e); } } + From 9f78b3040af673ddfff506af10015759a398cc81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=B6nermann?= Date: Fri, 8 May 2026 02:05:29 +0200 Subject: [PATCH 2/3] feat(widget): expose Codex rate limits via fetch_local_usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `CodexUsage` + `CodexWindowUsage` mirrors of the TS interfaces in `src/types.ts`, projected from the sidecar's `codexRateLimits` JSON into `LocalUsageSummary.codex_usage`. The frontend reads this through the existing `fetch_local_usage` Tauri command — no new round-trip. Schema-drift safety: deserialization failures log to stderr instead of silently dropping the field, so a sidecar shape change surfaces as a "why is the Codex toggle null?" debug clue rather than a mystery. Re-applies the C16 widget error-recovery fix from master `fd4368c` — the backup branch I rebased from didn't include it, so the manual restore here is just a safety net (net-zero diff vs master for that hunk). Co-Authored-By: Claude Opus 4.7 (1M context) --- widget/src-tauri/src/api_types.rs | 25 +++++++++++++++++++++++++ widget/src-tauri/src/commands.rs | 15 ++++++++++++++- widget/src/main.ts | 4 ++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/widget/src-tauri/src/api_types.rs b/widget/src-tauri/src/api_types.rs index 10eb949..19d5943 100644 --- a/widget/src-tauri/src/api_types.rs +++ b/widget/src-tauri/src/api_types.rs @@ -39,6 +39,27 @@ pub struct SettingsDisplay { pub saved_at: Option, } +/// Mirror of TokenBBQ's CodexWindowUsage TS interface (camelCase JSON). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexWindowUsage { + pub utilization: f64, + pub window_minutes: u32, + pub resets_at: Option, +} + +/// Live Codex rate-limit snapshot received from the sidecar. Mirrors +/// the TS `CodexRateLimits` interface in `src/types.ts`. The widget +/// renders these values in the pill when the Codex toggle is on. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexUsage { + pub plan_type: Option, + pub primary: Option, + pub secondary: Option, + pub snapshot_at: String, +} + /// Tight projection of TokenBBQ's DashboardData — just what the widget needs. /// Built by `fetch_local_usage` from the JSON output of `tokenbbq scan`. /// Token totals exclude `cacheRead` and `cacheCreation` — see @@ -56,6 +77,10 @@ pub struct LocalUsageSummary { /// Per-source breakdown for `today_date`. Order is whatever `tokenbbq scan` /// emits; the UI re-sorts client-side. pub today_by_source: Vec, + /// Live Codex rate-limit snapshot, projected from the sidecar JSON. + /// None when Codex isn't installed, no rate-limits event was ever + /// emitted, or the user has API-key auth (plan_type null). + pub codex_usage: Option, } #[derive(Debug, Clone, Serialize)] diff --git a/widget/src-tauri/src/commands.rs b/widget/src-tauri/src/commands.rs index 94b94f8..008dcce 100644 --- a/widget/src-tauri/src/commands.rs +++ b/widget/src-tauri/src/commands.rs @@ -5,7 +5,7 @@ use std::os::windows::process::CommandExt; use tauri::{AppHandle, State}; use tauri_plugin_store::StoreExt; -use crate::api_types::{ClaudeUsageResponse, LocalUsageSummary, Settings, SettingsDisplay, SourceSpend}; +use crate::api_types::{ClaudeUsageResponse, CodexUsage, LocalUsageSummary, Settings, SettingsDisplay, SourceSpend}; const USER_AGENT: &str = concat!("TokenBBQ-Widget/", env!("CARGO_PKG_VERSION")); @@ -431,12 +431,25 @@ pub async fn fetch_local_usage() -> Result { _ => Vec::new(), }; + // Schema-drift safety: log on deserialization failure rather than + // silently swallowing — otherwise a sidecar shape change would + // produce a `null` Codex toggle in the widget with no clue why. + let codex_usage: Option = raw + .get("codexRateLimits") + .and_then(|v| if v.is_null() { None } else { Some(v.clone()) }) + .and_then(|v| { + serde_json::from_value::(v) + .map_err(|e| eprintln!("tokenbbq-widget: codexRateLimits deserialize failed: {e}")) + .ok() + }); + Ok(LocalUsageSummary { generated, today_date, today_tokens, week_tokens, today_by_source, + codex_usage, }) } diff --git a/widget/src/main.ts b/widget/src/main.ts index 813dd1f..264b411 100644 --- a/widget/src/main.ts +++ b/widget/src/main.ts @@ -67,6 +67,10 @@ async function fetchUsage(): Promise { } } catch (e) { renderError(String(e)); + // Drop the cached payload so the next successful fetch re-renders even + // if claude.ai returns the exact same JSON it did before the error — + // otherwise the UI stays stuck on "err" until the upstream values move. + lastUsageJson = ""; } } From 21343b8df42b0e50618e3011fa74f66e8609eef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=B6nermann?= Date: Fri, 8 May 2026 02:05:50 +0200 Subject: [PATCH 3/3] fix(dashboard): inline brand PNG into CLI bundle, drop divergent SVG mark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard header rendered a stylized "T-coin in flames" SVG glyph that didn't match the widget's actual brand mark (the photoreal PNG in widget/src/assets/tokenbbq-icon.png). Embed the PNG at build time and serve it as a base64 data URL so the dashboard ships a single source of truth for the brand without depending on a runtime file path — external file paths don't survive Bun --compile (same constraint as the existing scripts/inline-wasm.mjs). - New `scripts/inline-dashboard-icon.mjs` reads the widget PNG and writes `src/dashboard-icon.ts` (gitignored). Uses `fileURLToPath(import.meta.url)` so it runs on Node 20.0+ — the CI matrix tests Node 20/22/24, and `import.meta.dirname` only landed in 20.11. - The build chain integrates the inliner the same way it integrates inline-wasm.mjs (chained `&&` in build/dev/lint/test/build:sidecar) rather than via prebuild/predev hooks, matching the existing pattern on master. - `dashboard.ts` drops the inline SVG glyph in favor of `` with `DASHBOARD_BRAND_ICON_DATA_URL` (TOKENBBQ_LOGO_PATH override still wins). A small inline SVG favicon link replaces /favicon.ico 404s. Bundle grows ~1.8 MB; user-facing the dashboard now matches the widget. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 6 ++++++ package.json | 10 +++++----- scripts/inline-dashboard-icon.mjs | 5 ++++- src/dashboard.ts | 30 +++++------------------------- 4 files changed, 20 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index 8d3beef..21c1ac4 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,9 @@ Thumbs.db # Claude Code local state .claude/ CLAUDE.md + +# Local planning notes +LOCAL-AUTH-DISCOVERY.md + +# Generated by scripts/inline-dashboard-icon.mjs at build time. +src/dashboard-icon.ts diff --git a/package.json b/package.json index 26b48f1..474ae08 100644 --- a/package.json +++ b/package.json @@ -42,13 +42,13 @@ "node": ">=20" }, "scripts": { - "build": "node scripts/inline-wasm.mjs && tsdown && node -e \"require('fs').renameSync('dist/index.mjs','dist/index.js')\"", - "dev": "node scripts/inline-wasm.mjs && node --import tsx src/index.ts", + "build": "node scripts/inline-wasm.mjs && node scripts/inline-dashboard-icon.mjs && tsdown && node -e \"require('fs').renameSync('dist/index.mjs','dist/index.js')\"", + "dev": "node scripts/inline-wasm.mjs && node scripts/inline-dashboard-icon.mjs && node --import tsx src/index.ts", "start": "node dist/index.js", - "lint": "node scripts/inline-wasm.mjs && tsc --noEmit", - "test": "node scripts/run-tests.mjs", + "lint": "node scripts/inline-wasm.mjs && node scripts/inline-dashboard-icon.mjs && tsc --noEmit", + "test": "node scripts/inline-dashboard-icon.mjs && node scripts/run-tests.mjs", "prepublishOnly": "npm run build", - "build:sidecar": "node scripts/inline-wasm.mjs && node scripts/build-sidecar.mjs", + "build:sidecar": "node scripts/inline-wasm.mjs && node scripts/inline-dashboard-icon.mjs && node scripts/build-sidecar.mjs", "widget:install": "npm install --prefix widget", "widget:dev": "node scripts/build-sidecar.mjs --skip-if-no-bun && npm run --prefix widget tauri dev", "widget:build": "npm run build && npm run build:sidecar && npm run --prefix widget tauri build" diff --git a/scripts/inline-dashboard-icon.mjs b/scripts/inline-dashboard-icon.mjs index b5a30e0..7b8c7b6 100644 --- a/scripts/inline-dashboard-icon.mjs +++ b/scripts/inline-dashboard-icon.mjs @@ -5,8 +5,11 @@ // scripts/inline-wasm.mjs (which solves the same Bun --compile problem). import { readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -const repoRoot = path.resolve(import.meta.dirname, '..'); +// `import.meta.dirname` lands in Node 20.11; CI matrix includes 20.0, +// so use `fileURLToPath(import.meta.url)` for portability. +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const src = path.join(repoRoot, 'widget', 'src', 'assets', 'tokenbbq-icon.png'); const dst = path.join(repoRoot, 'src', 'dashboard-icon.ts'); diff --git a/src/dashboard.ts b/src/dashboard.ts index bd8f942..d2c83af 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -1,6 +1,7 @@ import type { DashboardData } from './types.js'; import { SOURCE_COLORS, SOURCE_LABELS } from './types.js'; import { SOURCE_ORDER } from './aggregator.js'; +import { DASHBOARD_BRAND_ICON_DATA_URL } from './dashboard-icon.js'; export function renderDashboard(data: DashboardData, options?: any): string { const jsonData = JSON.stringify(data).replace(/ TokenBBQ Dashboard +`, + )}">