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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/run-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
96 changes: 55 additions & 41 deletions widget/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 /
Expand Down Expand Up @@ -41,6 +42,17 @@ let localPollTimer: ReturnType<typeof setInterval> | 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;
Expand All @@ -51,26 +63,39 @@ 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<void> {
try {
const usage = await invoke<ClaudeUsageResponse>("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.
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 = "";
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(() => {});
}
}
}

Expand All @@ -82,28 +107,25 @@ async function fetchLocalUsage(): Promise<void> {
try {
const local = await invoke<LocalUsageSummary>("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());
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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.
});
}

Expand Down
10 changes: 4 additions & 6 deletions widget/src/source-toggle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
47 changes: 32 additions & 15 deletions widget/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down
Loading
Loading