diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 2369649..6d8bf28 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -8,7 +8,7 @@ import { spawn } from 'node:child_process'; import { glob } from 'tinyglobby'; -const files = await glob('src/**/*.test.ts'); +const files = await glob(['src/**/*.test.ts', 'widget/tests/**/*.test.ts']); if (files.length === 0) { console.error('[run-tests] no test files matched src/**/*.test.ts'); process.exit(1); diff --git a/widget/src/main.ts b/widget/src/main.ts index 36bea73..4975644 100644 --- a/widget/src/main.ts +++ b/widget/src/main.ts @@ -5,8 +5,9 @@ import { availableMonitors, getCurrentWindow, PhysicalPosition } from "@tauri-ap import { getCurrentWebview } from "@tauri-apps/api/webview"; import type { ClaudeUsageResponse, LocalUsageSummary, SettingsDisplay } from "./types"; import { loadToggleState, saveToggleState, resolveMode, type SourceToggleState } from "./source-toggle"; -import { renderCompact, renderExpanded, renderError, renderLocalCompact, setViewState, getWorkAreaPhysical, currentFrameInsetLogical, clampWindowToWorkAreaOnce, refreshPillPositionIfPillMode, setMonitorWorkAreaPhysical, refitExpandedHeight } from "./ui"; +import { renderCompact, renderExpanded, renderLocalCompact, setViewState, getWorkAreaPhysical, currentFrameInsetLogical, clampWindowToWorkAreaOnce, refreshPillPositionIfPillMode, setMonitorWorkAreaPhysical, refitExpandedHeight } from "./ui"; import { scheduleAutoUpdateCheck, setupUpdateControls } from "./update"; +import { describeClaudeFailure, describeLocalFailure, keepLastGoodOnClaudeFailure, keepLastGoodOnLocalFailure, usageForRender, type UsageIssue } from "./usage-state"; const LOCAL_POLL_INTERVAL_MS = 5 * 60 * 1000; // Persistent cache of the last successful fetchLocalUsage result. Codex / @@ -41,6 +42,17 @@ let localPollTimer: ReturnType | null = null; let lastUsageJson = ""; let lastLocal: LocalUsageSummary | null = null; let toggleState: SourceToggleState = loadToggleState(); +let claudeIssue: UsageIssue | null = null; +let localIssue: UsageIssue | null = null; + +function lastClaudeUsage(): ClaudeUsageResponse | null { + if (!lastUsageJson) return null; + try { + return JSON.parse(lastUsageJson) as ClaudeUsageResponse; + } catch { + return null; + } +} function currentMode() { const usage = lastUsageJson ? JSON.parse(lastUsageJson) as ClaudeUsageResponse : null; @@ -51,14 +63,23 @@ function currentMode() { return resolveMode(toggleState, hasClaude, hasCodex); } +function currentIssues(): UsageIssue[] { + const issues: UsageIssue[] = []; + if (toggleState.claude && claudeIssue) issues.push(claudeIssue); + if ((toggleState.codex || lastLocal) && localIssue) issues.push(localIssue); + return issues; +} + async function fetchUsage(): Promise { try { const usage = await invoke("fetch_usage"); const json = JSON.stringify(usage); - if (json === lastUsageJson) return; + const hadIssue = claudeIssue !== null; + claudeIssue = null; + if (json === lastUsageJson && !hadIssue) return; lastUsageJson = json; renderCompact(usage, lastLocal, toggleState); - renderExpanded(usage, lastLocal, toggleState); + renderExpanded(usage, lastLocal, toggleState, currentIssues()); // 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. @@ -66,11 +87,15 @@ async function fetchUsage(): Promise { 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 = ""; + console.warn("fetch_usage failed:", e); + const lastGood = lastClaudeUsage(); + claudeIssue = describeClaudeFailure(e, lastGood); + const usage = usageForRender(keepLastGoodOnClaudeFailure(lastGood)); + renderCompact(usage, lastLocal, toggleState); + renderExpanded(usage, lastLocal, toggleState, currentIssues()); + if (currentView === "compact") { + setViewState("compact", currentMode()).catch(() => {}); + } } } @@ -82,28 +107,25 @@ async function fetchLocalUsage(): Promise { try { const local = await invoke("fetch_local_usage"); lastLocal = local; + localIssue = null; saveCachedLocalUsage(local); renderLocalCompact(local); - // 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; - renderCompact(usage, local, toggleState); - renderExpanded(usage, local, toggleState); - if (currentView === "compact") { - setViewState("compact", currentMode()).catch(() => {}); - } - } catch {} + // Re-render pill + expanded with whatever Claude state exists. This keeps + // Codex/local values live even when Claude Code is not connected. + const usage = usageForRender(lastClaudeUsage()); + renderCompact(usage, local, toggleState); + renderExpanded(usage, local, toggleState, currentIssues()); + if (currentView === "compact") { + setViewState("compact", currentMode()).catch(() => {}); } } catch (e) { - // Sidecar unavailable / errored → degrade gracefully: hide the local zone, - // keep claude.ai data visible. Console-only so we don't drown the user. + // Sidecar unavailable / errored: keep the last good local snapshot visible. console.warn("fetch_local_usage failed:", e); - lastLocal = null; - renderLocalCompact(null); + localIssue = describeLocalFailure(e, lastLocal); + lastLocal = keepLastGoodOnLocalFailure(lastLocal); + renderLocalCompact(lastLocal); + const usage = usageForRender(lastClaudeUsage()); + renderExpanded(usage, lastLocal, toggleState, currentIssues()); } } @@ -526,13 +548,9 @@ function setupEventListeners(): void { 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 {} - } + const usage = usageForRender(lastClaudeUsage()); + renderCompact(usage, lastLocal, toggleState); + renderExpanded(usage, lastLocal, toggleState, currentIssues()); }); // Theme @@ -552,15 +570,11 @@ function setupEventListeners(): void { extraToggle.checked = localStorage.getItem("tokenbbq-show-extra-usage") === "1"; extraToggle.addEventListener("change", () => { localStorage.setItem("tokenbbq-show-extra-usage", extraToggle.checked ? "1" : "0"); - if (lastUsageJson) { - try { - const usage = JSON.parse(lastUsageJson) as ClaudeUsageResponse; - renderExpanded(usage, lastLocal, toggleState); - // The user is in the settings overlay; the underlying panel just - // grew/shrunk by one row. Defer resizing to closeSettings so we - // don't yank window dimensions out from under their interaction. - } catch {} - } + const usage = usageForRender(lastClaudeUsage()); + renderExpanded(usage, lastLocal, toggleState, currentIssues()); + // The user is in the settings overlay; the underlying panel just + // grew/shrunk by one row. Defer resizing to closeSettings so we + // don't yank window dimensions out from under their interaction. }); } diff --git a/widget/src/source-toggle.ts b/widget/src/source-toggle.ts index e8d1134..d0a761c 100644 --- a/widget/src/source-toggle.ts +++ b/widget/src/source-toggle.ts @@ -47,14 +47,12 @@ export function saveToggleState(state: SourceToggleState): void { */ export function resolveMode( state: SourceToggleState, - hasClaudeData: boolean, - hasCodexData: boolean, + _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'; + if (state.claude && state.codex) return 'both'; + if (state.codex) return 'codex'; return 'claude'; } diff --git a/widget/src/styles.css b/widget/src/styles.css index 225769d..3ea2ed1 100644 --- a/widget/src/styles.css +++ b/widget/src/styles.css @@ -611,24 +611,41 @@ body.view-transitioning { .progress-fill.orange { background: var(--orange); box-shadow: 0 0 8px var(--orange-glow); } .progress-fill.red { background: var(--red); box-shadow: 0 0 8px var(--red-glow); } -/* Error banner */ -.error-banner { - background: rgba(239, 68, 68, 0.08); - border: 1px solid rgba(239, 68, 68, 0.2); - border-radius: 10px; - padding: 12px; - font-size: 12px; +.usage-issue { + margin: 8px 0 2px; + padding: 9px 10px; + border-radius: 8px; + border: 1px solid var(--border-subtle); + background: var(--surface); +} + +.usage-issue.offline { + border-color: rgba(236, 93, 93, 0.22); + background: rgba(236, 93, 93, 0.07); +} + +.usage-issue.stale { + border-color: rgba(232, 123, 53, 0.22); + background: var(--accent-dim); +} + +.usage-issue-title { + font-size: 11px; + font-weight: 600; + color: var(--text-primary); +} + +.usage-issue-message { + margin-top: 3px; + font-size: 11px; + line-height: 1.45; color: var(--text-secondary); - display: flex; - align-items: flex-start; - gap: 8px; - line-height: 1.5; } -.error-icon { - color: var(--red); - font-size: 14px; - flex-shrink: 0; +.usage-issue-message code { + font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace; + font-size: 10px; + color: var(--text-primary); } /* Settings footer (settings overlay only) */ diff --git a/widget/src/ui.ts b/widget/src/ui.ts index 0cd54e0..fe4546b 100644 --- a/widget/src/ui.ts +++ b/widget/src/ui.ts @@ -1,6 +1,7 @@ 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"; +import { formatOptionalUtilization, hasUtilization, type UsageIssue } from "./usage-state"; const COMPACT_SIZE_SINGLE = { width: 320, height: 64 }; // Identical width to single — only the height grows for the second @@ -70,6 +71,10 @@ export function utilizationColor(pct: number): string { return `var(--${colorTier(pct)})`; } +function optionalUtilizationColor(pct: number | null | undefined): string { + return hasUtilization(pct) ? utilizationColor(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 { @@ -274,7 +279,7 @@ export function renderCompact( secondaryLogo.innerHTML = ''; } - if (mode === 'both' && codex) { + if (mode === 'both') { // 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. @@ -287,52 +292,52 @@ export function renderCompact( 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); + const fhPctC = usage.five_hour?.utilization; + const sdPctC = usage.seven_day?.utilization; + fiveHour.textContent = formatOptionalUtilization(fhPctC); + fiveHour.style.color = optionalUtilizationColor(fhPctC); + sevenDay.textContent = formatOptionalUtilization(sdPctC); + sevenDay.style.color = optionalUtilizationColor(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 fhPctX = codex?.primary?.utilization; + const sdPctX = codex?.secondary?.utilization; 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"; + fh2.textContent = formatOptionalUtilization(fhPctX); + fh2.style.color = optionalUtilizationColor(fhPctX); + sd2.textContent = formatOptionalUtilization(sdPctX); + sd2.style.color = optionalUtilizationColor(sdPctX); + fh2l.textContent = formatHoursCompact(codex?.primary?.resetsAt ?? null) || "5h"; + sd2l.textContent = formatDaysCompact(codex?.secondary?.resetsAt ?? null) || "7d"; return; } - if (mode === 'codex' && codex) { + if (mode === '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"; + const fhPct = codex?.primary?.utilization; + const sdPct = codex?.secondary?.utilization; + fiveHour.textContent = formatOptionalUtilization(fhPct); + fiveHour.style.color = optionalUtilizationColor(fhPct); + sevenDay.textContent = formatOptionalUtilization(sdPct); + sevenDay.style.color = optionalUtilizationColor(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); + const fhPct = usage.five_hour?.utilization; + const sdPct = usage.seven_day?.utilization; + fiveHour.textContent = formatOptionalUtilization(fhPct); + fiveHour.style.color = optionalUtilizationColor(fhPct); + sevenDay.textContent = formatOptionalUtilization(sdPct); + sevenDay.style.color = optionalUtilizationColor(sdPct); fiveHourLabel.textContent = formatHoursCompact(usage.five_hour?.resets_at ?? null) || "5h"; sevenDayLabel.textContent = formatDaysCompact(usage.seven_day?.resets_at ?? null) || "7d"; } @@ -436,6 +441,7 @@ export function renderExpanded( usage: ClaudeUsageResponse, local: LocalUsageSummary | null = null, toggleState: SourceToggleState = { claude: true, codex: false }, + issues: UsageIssue[] = [], ): void { const container = document.getElementById("usage-bars")!; const panel = document.getElementById("expanded-view")!; @@ -450,8 +456,9 @@ export function renderExpanded( let html = `
Compact view
`; 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 += toggleRowHtml('toggle-codex', 'Codex', codexBadgeSvg, CODEX_BRAND_COLOR, toggleState.codex, false, codexHint); html += `
`; + html += renderUsageIssuesHtml(issues); // Claude + Codex rate-limit blocks. Side-by-side when both have data; // single full-width column when only one is present. Hidden completely @@ -492,6 +499,17 @@ export function renderExpanded( // takes over and scrolls inside the fixed window. } +function renderUsageIssuesHtml(issues: UsageIssue[]): string { + if (issues.length === 0) return ""; + return issues + .map((issue) => ` +
+
${escapeHtml(issue.title)}
+
${formatIssueMessage(issue.message)}
+
`) + .join(""); +} + function renderLocalExpandedHtml(local: LocalUsageSummary): string { const sources = [...local.todayBySource].sort((a, b) => b.tokens - a.tokens); const dateLabel = local.todayDate ? formatRelativeDate(local.todayDate) : "—"; @@ -559,18 +577,8 @@ function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } -export function renderError(message: string): void { - document.getElementById("five-hour-compact")!.textContent = "—"; - document.getElementById("seven-day-compact")!.textContent = "err"; - // Errors here are claude.ai-specific; hide the local zone so we don't show - // stale numbers next to a broken half. - renderLocalCompact(null); - - document.getElementById("usage-bars")!.innerHTML = ` -
- - ${escapeHtml(message)} -
`; +function formatIssueMessage(s: string): string { + return escapeHtml(s).replace(/`([^`]+)`/g, '$1'); } // pillPosition is the pill's "home" — the visible top-left of the compact diff --git a/widget/src/usage-state.ts b/widget/src/usage-state.ts new file mode 100644 index 0000000..faab42f --- /dev/null +++ b/widget/src/usage-state.ts @@ -0,0 +1,69 @@ +import type { ClaudeUsageResponse, LocalUsageSummary } from "./types"; + +export interface UsageIssue { + source: "claude" | "local"; + title: string; + message: string; + compactText: string | null; + stale: boolean; +} + +export const EMPTY_CLAUDE_USAGE: ClaudeUsageResponse = { + five_hour: null, + seven_day: null, + extra_usage: null, +}; + +export function usageForRender( + lastGood: ClaudeUsageResponse | null, +): ClaudeUsageResponse { + return lastGood ?? EMPTY_CLAUDE_USAGE; +} + +export function keepLastGoodOnClaudeFailure( + lastGood: ClaudeUsageResponse | null, +): ClaudeUsageResponse | null { + return lastGood; +} + +export function keepLastGoodOnLocalFailure( + lastGood: LocalUsageSummary | null, +): LocalUsageSummary | null { + return lastGood; +} + +export function formatOptionalUtilization(value: number | null | undefined): string { + return typeof value === "number" && Number.isFinite(value) + ? `${Math.round(value)}%` + : "--"; +} + +export function hasUtilization(value: number | null | undefined): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +export function describeClaudeFailure( + _error: unknown, + _lastGood: ClaudeUsageResponse | null, +): UsageIssue | null { + return null; +} + +export function describeLocalFailure( + error: unknown, + lastGood: LocalUsageSummary | null, +): UsageIssue { + return { + source: "local", + title: lastGood ? "Showing last local values" : "Local usage unavailable", + message: normalizeErrorMessage(error), + compactText: null, + stale: lastGood !== null, + }; +} + +function normalizeErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + const message = String(error || "").trim(); + return message || "Refresh failed."; +} diff --git a/widget/tests/usage-state.test.ts b/widget/tests/usage-state.test.ts new file mode 100644 index 0000000..f4cafcf --- /dev/null +++ b/widget/tests/usage-state.test.ts @@ -0,0 +1,92 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; +import type { ClaudeUsageResponse } from '../src/types.js'; +import { resolveMode } from '../src/source-toggle.js'; +import { + keepLastGoodOnClaudeFailure, + keepLastGoodOnLocalFailure, + formatOptionalUtilization, + describeClaudeFailure, + describeLocalFailure, + usageForRender, +} from '../src/usage-state.js'; + +const usage: ClaudeUsageResponse = { + five_hour: { utilization: 42, resets_at: null }, + seven_day: { utilization: 7, resets_at: null }, + extra_usage: null, +}; + +test('source toggle determines compact layout even while data is temporarily unavailable', () => { + assert.equal(resolveMode({ claude: true, codex: true }, false, true), 'both'); + assert.equal(resolveMode({ claude: true, codex: true }, true, false), 'both'); + assert.equal(resolveMode({ claude: true, codex: false }, false, false), 'claude'); + assert.equal(resolveMode({ claude: false, codex: true }, false, false), 'codex'); + assert.equal(resolveMode({ claude: false, codex: false }, true, true), 'none'); +}); + +test('claude polling failures keep the last good claude usage payload', () => { + assert.equal(keepLastGoodOnClaudeFailure(usage), usage); + assert.equal(keepLastGoodOnClaudeFailure(null), null); +}); + +test('source toggles can re-render before claude has a last successful payload', () => { + assert.equal(usageForRender(usage), usage); + assert.deepEqual(usageForRender(null), { + five_hour: null, + seven_day: null, + extra_usage: null, + }); +}); + +test('local polling failures keep the last good local usage payload', () => { + const local = { + generated: '2026-05-17T12:00:00.000Z', + todayDate: '2026-05-17', + todayTokens: 1234, + weekTokens: 5678, + todayBySource: [{ source: 'claude-code', tokens: 1234 }], + codexUsage: null, + }; + + assert.equal(keepLastGoodOnLocalFailure(local), local); + assert.equal(keepLastGoodOnLocalFailure(null), null); +}); + +test('missing live utilization renders as neutral placeholder, not 0 percent or error text', () => { + assert.equal(formatOptionalUtilization(undefined), '--'); + assert.equal(formatOptionalUtilization(null), '--'); + assert.equal(formatOptionalUtilization(0), '0%'); + assert.equal(formatOptionalUtilization(14.4), '14%'); +}); + +test('claude failures stay out of user-facing chrome', () => { + const missingAuth = describeClaudeFailure( + 'Could not read C:\\Users\\me\\.claude\\.credentials.json: not found. Run `claude auth login` to create it.', + null, + ); + assert.equal(missingAuth, null); + + const stale = describeClaudeFailure('Network error: timed out', usage); + assert.equal(stale, null); +}); + +test('local failures become stale-status metadata while preserving the last local snapshot', () => { + const local = { + generated: '2026-05-17T12:00:00.000Z', + todayDate: '2026-05-17', + todayTokens: 1234, + weekTokens: 5678, + todayBySource: [{ source: 'codex', tokens: 1234 }], + codexUsage: null, + }; + + const stale = describeLocalFailure('sidecar exited', local); + assert.equal(stale.title, 'Showing last local values'); + assert.equal(stale.compactText, null); + assert.match(stale.message, /sidecar exited/); + + const empty = describeLocalFailure('sidecar not found', null); + assert.equal(empty.title, 'Local usage unavailable'); + assert.equal(empty.compactText, null); +});