From 8fd5428cb6cddd2256f0481228a24856a2fcb488 Mon Sep 17 00:00:00 2001 From: Andrii Kartava Date: Sun, 13 Sep 2026 17:48:29 +0300 Subject: [PATCH 1/2] Add `/usage` command for the Claude harness --- src/App.tsx | 36 +++- src/chrome/Composer.tsx | 5 +- src/lib/compact.ts | 5 +- src/lib/harness/apply.test.ts | 50 +++++ src/lib/harness/apply.ts | 7 + src/lib/harness/claude.ts | 4 + src/lib/harness/claudeLive.test.ts | 39 ++++ src/lib/harness/claudeProtocol.test.ts | 33 +++ src/lib/harness/claudeProtocol.ts | 27 +++ src/lib/harness/types.ts | 4 +- src/lib/rateLimits.test.ts | 53 +++++ src/lib/rateLimits.ts | 24 +++ src/lib/rateLimitsFetch.ts | 27 ++- src/lib/session.ts | 3 + src/lib/sessionUsage.test.ts | 58 ++++++ src/lib/sessionUsage.ts | 55 +++++ src/lib/skills.test.ts | 20 ++ src/lib/skills.ts | 9 +- src/lib/usage.test.ts | 265 +++++++++++++++++++++++++ src/lib/usage.ts | 210 ++++++++++++++++++++ 20 files changed, 920 insertions(+), 14 deletions(-) create mode 100644 src/lib/sessionUsage.test.ts create mode 100644 src/lib/sessionUsage.ts create mode 100644 src/lib/usage.test.ts create mode 100644 src/lib/usage.ts diff --git a/src/App.tsx b/src/App.tsx index 54d44736..fac2d75a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -308,6 +308,12 @@ import { } from "./lib/tabVisitHistory"; import { preparePrompt } from "./lib/promptPreparation"; import { warmNativeSkills, isNativeCommandPrompt } from "./lib/skills"; +import { fetchClaudeRateLimits } from "./lib/rateLimitsFetch"; +import { + formatUsageReport, + isUsageCommand, + USAGE_SNAPSHOT_MAX_AGE_MS, +} from "./lib/usage"; import { nativeSkillContextForSession } from "./lib/sessionSkills"; import { loadSessionFolders, @@ -3914,6 +3920,30 @@ export default function App({ [], ); + // The CLI answers /usage itself and never streams it, so the app does. + const showUsage = useCallback( + (sessionId: string) => { + void fetchClaudeRateLimits({ maxAgeMs: USAGE_SNAPSHOT_MAX_AGE_MS }).then( + (limits) => { + const session = sessionsRef.current.find((s) => s.id === sessionId); + if (!session || removingSessionIds.current.has(sessionId)) return; + enqueueHarnessEvent(sessionId, { + type: "status", + text: formatUsageReport( + { session, limits }, + { + now: Date.now(), + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, + ), + }); + flushHarnessEvents(); + }, + ); + }, + [enqueueHarnessEvent, flushHarnessEvents], + ); + const onSubmit = useCallback( ( sessionId: string, @@ -3933,6 +3963,10 @@ export default function App({ if (removingSessionIds.current.has(sessionId)) return; const storedCurrent = sessionsRef.current.find((s) => s.id === sessionId); if (!storedCurrent) return; + if (storedCurrent.harness === "claude" && isUsageCommand(text)) { + showUsage(sessionId); + return; + } const current = options?.buildTarget ? withPlanBuildTarget(storedCurrent, options.buildTarget) : storedCurrent; @@ -4434,7 +4468,7 @@ export default function App({ } })(); }, - [enqueueHarnessEvent, flushHarnessEvents], + [enqueueHarnessEvent, flushHarnessEvents, showUsage], ); const onUpdatePlan = useCallback( diff --git a/src/chrome/Composer.tsx b/src/chrome/Composer.tsx index d2a0283a..378578e7 100644 --- a/src/chrome/Composer.tsx +++ b/src/chrome/Composer.tsx @@ -124,6 +124,7 @@ import { useComposerSkills } from "./useComposerSkills"; import { Popover } from "./Popover"; import { consumePlanCommand, PLAN_COMMAND } from "../lib/plan"; import { COMPACT_COMMAND, isCompactCommand } from "../lib/compact"; +import { USAGE_COMMAND } from "../lib/usage"; import { consumeSessionFolderCommand, isSessionFolderCommand, @@ -525,15 +526,17 @@ export function Composer({ SESSION_FOLDER_COMMAND, PLAN_COMMAND, COMPACT_COMMAND, + ...(harness === "claude" ? [USAGE_COMMAND] : []), ...skills.filter( (skill) => skill.kind === "native" || (skill.name !== PLAN_COMMAND.name && skill.name !== COMPACT_COMMAND.name && + skill.name !== USAGE_COMMAND.name && skill.name !== SESSION_FOLDER_COMMAND.name), ), ], - [skills], + [harness, skills], ); const skillLimit = hasNativeCommands(harness) ? Number.POSITIVE_INFINITY diff --git a/src/lib/compact.ts b/src/lib/compact.ts index 8658c5e5..139bbdcc 100644 --- a/src/lib/compact.ts +++ b/src/lib/compact.ts @@ -1,4 +1,4 @@ -import type { BuiltinSkill } from "./skills"; +import { isStandaloneCommand, type BuiltinSkill } from "./skills"; export const COMPACT_COMMAND: BuiltinSkill = { kind: "builtin", @@ -9,7 +9,6 @@ export const COMPACT_COMMAND: BuiltinSkill = { source: "monocode", }; -/** Match the standalone composer command without consuming ordinary prompt text. */ export function isCompactCommand(text: string): boolean { - return /^\s*\/compact\s*$/i.test(text); + return isStandaloneCommand(text, COMPACT_COMMAND.name); } diff --git a/src/lib/harness/apply.test.ts b/src/lib/harness/apply.test.ts index 5861cecb..1f63fa44 100644 --- a/src/lib/harness/apply.test.ts +++ b/src/lib/harness/apply.test.ts @@ -494,6 +494,56 @@ describe("applyHarnessEvent context", () => { }); }); +describe("usage totals", () => { + it("folds result totals into the session and leaves blocks alone", () => { + let session = newSession("claude", "/repo"); + session = applyHarnessEvent(session, { + type: "usage", + processCostUsd: 0.01, + processApiMs: 1_000, + turnTokens: { input: 1, output: 2, cacheRead: 3, cacheWrite: 4 }, + }); + session = applyHarnessEvent(session, { + type: "usage", + processCostUsd: 0.03, + processApiMs: 2_500, + turnTokens: { input: 1, output: 2, cacheRead: 3, cacheWrite: 4 }, + }); + expect(session.usage).toEqual({ + costUsd: 0.03, + apiMs: 2_500, + tokens: { input: 2, output: 4, cacheRead: 6, cacheWrite: 8 }, + lastProcessCostUsd: 0.03, + lastProcessApiMs: 2_500, + }); + expect(session.blocks).toEqual([]); + }); + + it("adds a new child's counters on top of the old total after a restart", () => { + let session = newSession("claude", "/repo"); + session = applyHarnessEvent(session, { + type: "usage", + processCostUsd: 0.5, + processApiMs: 100, + }); + session = applyHarnessEvent(session, { type: "session.started" }); + session = applyHarnessEvent(session, { + type: "usage", + processCostUsd: 0.6, + processApiMs: 200, + }); + expect(session.usage?.costUsd).toBeCloseTo(1.1); + expect(session.usage?.apiMs).toBe(300); + }); + + it("ignores session.started before any usage arrived", () => { + const session = applyHarnessEvent(newSession("claude", "/repo"), { + type: "session.started", + }); + expect(session.usage).toBeUndefined(); + }); +}); + describe("tool enrichment", () => { it("retains Edit and Write previews when a tool completes without repeating its input", () => { for (const [name, input] of [ diff --git a/src/lib/harness/apply.ts b/src/lib/harness/apply.ts index ab220eb6..ccd50840 100644 --- a/src/lib/harness/apply.ts +++ b/src/lib/harness/apply.ts @@ -8,6 +8,7 @@ import type { ToolPreview, } from "../session"; import { mergeContextUsage } from "../contextUsage"; +import { mergeSessionUsage, resetProcessCounters } from "../sessionUsage"; import { displayPath } from "../paths"; import { composeToolTitle, @@ -105,6 +106,12 @@ export function applyHarnessEvent( window: event.window, }), }; + case "usage": + return { ...session, usage: mergeSessionUsage(session.usage, event) }; + case "session.started": + return session.usage + ? { ...session, usage: resetProcessCounters(session.usage) } + : session; case "tasks.updated": return upsertTaskList(session, event); case "plan": diff --git a/src/lib/harness/claude.ts b/src/lib/harness/claude.ts index 01586317..d1378a77 100644 --- a/src/lib/harness/claude.ts +++ b/src/lib/harness/claude.ts @@ -17,6 +17,7 @@ import { assistantThinkingBlocks, assistantToolUses, contextFromResult, + usageFromResult, contextUsedFromAssistant, buildClaudeSpawnArgs, buildClaudeUserMessage, @@ -758,6 +759,9 @@ function handleResult(live: Live, rec: Record): void { const context = contextFromResult(rec); if (context) live.onEvent({ type: "context", ...context }); } + // Compaction spends real tokens too, so its result counts. + const usage = usageFromResult(rec); + if (usage) live.onEvent({ type: "usage", ...usage }); const result = turnStatusFromResult(rec); if (result.status === "failed" && result.error && !live.cancelled) { diff --git a/src/lib/harness/claudeLive.test.ts b/src/lib/harness/claudeLive.test.ts index 6fc797ab..bbd8a97f 100644 --- a/src/lib/harness/claudeLive.test.ts +++ b/src/lib/harness/claudeLive.test.ts @@ -196,6 +196,45 @@ describe("claude subagents", () => { }, ); + it("emits session usage from the turn result and ignores subagent results", async () => { + const { events, turn } = await startTurn("s1"); + emit({ + type: "result", + subtype: "success", + session_id: "sess_1", + parent_tool_use_id: "agent_1", + total_cost_usd: 9, + duration_api_ms: 9_000, + }); + emit({ + type: "result", + subtype: "success", + session_id: "sess_1", + total_cost_usd: 0.013, + duration_api_ms: 2_751, + usage: { + input_tokens: 10, + output_tokens: 31, + cache_read_input_tokens: 19_640, + cache_creation_input_tokens: 139, + }, + }); + await turn; + expect(events.filter((event) => event.type === "usage")).toEqual([ + { + type: "usage", + processCostUsd: 0.013, + processApiMs: 2_751, + turnTokens: { + input: 10, + output: 31, + cacheRead: 19_640, + cacheWrite: 139, + }, + }, + ]); + }); + it("keeps simultaneous child questions reachable in the single-question UI", async () => { const { events, turn } = await startTurn("s1"); for (const id of ["child_a", "child_b"]) { diff --git a/src/lib/harness/claudeProtocol.test.ts b/src/lib/harness/claudeProtocol.test.ts index aeeddd2c..44ba70fd 100644 --- a/src/lib/harness/claudeProtocol.test.ts +++ b/src/lib/harness/claudeProtocol.test.ts @@ -7,6 +7,7 @@ import { buildClaudeUserMessage, contextFromResult, contextUsedFromAssistant, + usageFromResult, extractExitPlanModePlan, isClaudeInitMessage, isSubagentMessage, @@ -570,6 +571,38 @@ describe("contextUsedFromAssistant", () => { }); }); +describe("usageFromResult", () => { + it("reads cost, API time, and the four token counts", () => { + expect( + usageFromResult({ + type: "result", + total_cost_usd: 0.0108758, + duration_ms: 1810, + duration_api_ms: 1759, + usage: { + input_tokens: 10, + cache_creation_input_tokens: 4522, + cache_read_input_tokens: 15118, + output_tokens: 62, + }, + }), + ).toEqual({ + processCostUsd: 0.0108758, + processApiMs: 1759, + turnTokens: { input: 10, output: 62, cacheRead: 15118, cacheWrite: 4522 }, + }); + }); + + it("omits fields the result does not carry", () => { + expect(usageFromResult({ type: "result", total_cost_usd: 0.2 })).toEqual({ + processCostUsd: 0.2, + }); + expect( + usageFromResult({ type: "result", subtype: "success" }), + ).toBeUndefined(); + }); +}); + describe("contextFromResult", () => { it("reads the window the CLI reports rather than a model table", () => { const rec = { diff --git a/src/lib/harness/claudeProtocol.ts b/src/lib/harness/claudeProtocol.ts index c948eb11..1a49228d 100644 --- a/src/lib/harness/claudeProtocol.ts +++ b/src/lib/harness/claudeProtocol.ts @@ -4,6 +4,7 @@ import type { TaskListItem, ToolPreview, } from "../session"; +import type { TurnUsage } from "../sessionUsage"; import { attachmentPathText } from "../attachments"; import { isTaskListToolName, taskListFromToolInput } from "../taskList"; import { @@ -1057,3 +1058,29 @@ export function contextFromResult( if (!used && !window) return undefined; return { used: used > 0 ? used : undefined, window }; } + +export function usageFromResult( + rec: Record, +): TurnUsage | undefined { + const processCostUsd = optionalNumber(rec.total_cost_usd); + const processApiMs = optionalNumber(rec.duration_api_ms); + const usage = asRecord(rec.usage); + const turnTokens = usage + ? { + input: numberField(usage, "input_tokens"), + output: numberField(usage, "output_tokens"), + cacheRead: numberField(usage, "cache_read_input_tokens"), + cacheWrite: numberField(usage, "cache_creation_input_tokens"), + } + : undefined; + if (processCostUsd == null && processApiMs == null && !turnTokens) { + return undefined; + } + return { processCostUsd, processApiMs, turnTokens }; +} + +function optionalNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; +} diff --git a/src/lib/harness/types.ts b/src/lib/harness/types.ts index 48aaea98..e98e3fd0 100644 --- a/src/lib/harness/types.ts +++ b/src/lib/harness/types.ts @@ -6,6 +6,7 @@ import type { ToolPreview, TurnIntent, } from "../session"; +import type { TurnUsage } from "../sessionUsage"; import type { UserQuestion } from "../userQuestion"; export type HarnessEvent = @@ -114,7 +115,8 @@ export type HarnessEvent = streaming?: boolean; } /** Context-window level after the harness's latest request. */ - | { type: "context"; used?: number; window?: number }; + | { type: "context"; used?: number; window?: number } + | ({ type: "usage" } & TurnUsage); export type ApprovalDecision = "allow" | "deny"; diff --git a/src/lib/rateLimits.test.ts b/src/lib/rateLimits.test.ts index 86fe2e4c..2c527d81 100644 --- a/src/lib/rateLimits.test.ts +++ b/src/lib/rateLimits.test.ts @@ -129,6 +129,59 @@ describe("parseClaudeOAuthUsage", () => { ); }); + it("maps weekly_scoped limits to per-model weekly windows", () => { + const limits = parseClaudeOAuthUsage( + JSON.stringify({ + five_hour: { utilization: 6, resets_at: "2026-09-13T16:50:00Z" }, + seven_day: { utilization: 28, resets_at: "2026-09-15T08:00:00Z" }, + limits: [ + { kind: "session", group: "session", percent: 6, scope: null }, + { kind: "weekly_all", group: "weekly", percent: 28, scope: null }, + { + kind: "weekly_scoped", + group: "weekly", + percent: 49, + resets_at: "2026-09-15T08:00:00Z", + scope: { model: { id: null, display_name: "Fable" }, surface: null }, + }, + { + kind: "weekly_scoped", + group: "weekly", + percent: 12, + resets_at: "2026-09-15T08:00:00Z", + scope: { model: { id: "claude-opus-5", display_name: "" } }, + }, + { kind: "weekly_scoped", group: "weekly", percent: 1, scope: {} }, + ], + }), + ); + expect(limits.weeklyByModel).toEqual([ + { + label: "Fable", + window: { + usedPercent: 49, + windowMinutes: 10_080, + resetsAt: Date.parse("2026-09-15T08:00:00Z"), + }, + }, + { + label: "claude-opus-5", + window: { + usedPercent: 12, + windowMinutes: 10_080, + resetsAt: Date.parse("2026-09-15T08:00:00Z"), + }, + }, + ]); + }); + + it("leaves weeklyByModel empty without a limits array", () => { + const limits = parseClaudeOAuthUsage( + JSON.stringify({ five_hour: { utilization: 1 } }), + ); + expect(limits.weeklyByModel).toEqual([]); + }); + it("returns an error for garbage", () => { const limits = parseClaudeOAuthUsage("not json"); expect(limits.status).toBe("error"); diff --git a/src/lib/rateLimits.ts b/src/lib/rateLimits.ts index f242dea4..e89a35b4 100644 --- a/src/lib/rateLimits.ts +++ b/src/lib/rateLimits.ts @@ -1,3 +1,4 @@ +import { stringField } from "./harness/claudeProtocol"; import { asRecord } from "./harness/codexProtocol"; export type RateLimitProvider = "claude" | "codex"; @@ -18,11 +19,14 @@ export type ProviderRateLimits = { provider: RateLimitProvider; session: RateLimitWindow | null; weekly: RateLimitWindow | null; + weeklyByModel?: ScopedRateLimitWindow[]; updatedAt: number; error: string | null; status: RateLimitStatus; }; +export type ScopedRateLimitWindow = { label: string; window: RateLimitWindow }; + export const SESSION_WINDOW_MINUTES = 300; export const WEEKLY_WINDOW_MINUTES = 10_080; @@ -271,12 +275,32 @@ export function parseClaudeOAuthUsage(body: string): ProviderRateLimits { provider: "claude", session: mapUsageWindow(rec.five_hour, SESSION_WINDOW_MINUTES), weekly: mapUsageWindow(rec.seven_day, WEEKLY_WINDOW_MINUTES), + weeklyByModel: scopedWeeklyWindows(rec.limits), updatedAt: Date.now(), error: null, status: "ok", }; } +function scopedWeeklyWindows(raw: unknown): ScopedRateLimitWindow[] { + if (!Array.isArray(raw)) return []; + const out: ScopedRateLimitWindow[] = []; + for (const item of raw) { + const rec = asRecord(item); + if (!rec || rec.kind !== "weekly_scoped") continue; + const model = asRecord(asRecord(rec.scope)?.model); + const label = + stringField(model, "display_name") ?? stringField(model, "id"); + const window = mapUsageWindow( + { utilization: rec.percent, resets_at: rec.resets_at }, + WEEKLY_WINDOW_MINUTES, + ); + if (!label || !window) continue; + out.push({ label, window }); + } + return out; +} + type CodexWindowSnapshot = { usedPercent: number; windowDurationMins: number | null; diff --git a/src/lib/rateLimitsFetch.ts b/src/lib/rateLimitsFetch.ts index 275f7526..6d7e1e7d 100644 --- a/src/lib/rateLimitsFetch.ts +++ b/src/lib/rateLimitsFetch.ts @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { homeDir } from "./fs"; import { errorRateLimits, + isRateLimitSnapshotStale, parseClaudeOAuthUsage, parseCodexRateLimits, unavailableRateLimits, @@ -28,16 +29,28 @@ type ClaudeUsageFetch = { error?: string | null; }; -export async function fetchClaudeRateLimits(): Promise { +let lastClaudeSnapshot: ProviderRateLimits | null = null; + +/** Every caller shares one snapshot; `maxAgeMs` skips the keychain and network. */ +export async function fetchClaudeRateLimits(options?: { + maxAgeMs?: number; +}): Promise { + if ( + options?.maxAgeMs != null && + lastClaudeSnapshot && + !isRateLimitSnapshotStale(lastClaudeSnapshot, Date.now(), options.maxAgeMs) + ) { + return lastClaudeSnapshot; + } + lastClaudeSnapshot = await fetchClaudeRateLimitsNow(); + return lastClaudeSnapshot; +} + +async function fetchClaudeRateLimitsNow(): Promise { try { const result = await invoke("fetch_claude_usage"); if (result.status === "ok" && result.body) { - const parsed = parseClaudeOAuthUsage(result.body); - if (parsed.session || parsed.weekly) return parsed; - return { - ...parsed, - status: parsed.status === "ok" ? "ok" : parsed.status, - }; + return parseClaudeOAuthUsage(result.body); } if (result.status === "unavailable") { return unavailableRateLimits( diff --git a/src/lib/session.ts b/src/lib/session.ts index 8f11f648..5413ad03 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -1,4 +1,5 @@ import type { ContextUsage } from "./contextUsage"; +import type { SessionUsage } from "./sessionUsage"; import type { UserQuestionPrompt } from "./userQuestion"; import type { HandoffComposerCard } from "./handoff"; import type { InboxComposerCard } from "./githubTasks"; @@ -275,6 +276,8 @@ export type Session = { providerSessionId?: string; /** Context-window level reported by the harness. Absent until it reports. */ context?: ContextUsage; + /** Running totals from Claude turn results. In-memory only. */ + usage?: SessionUsage; /** * Composer switched providers, but the previous child is still live. * Handoff runs on the next send, not on picker change. diff --git a/src/lib/sessionUsage.test.ts b/src/lib/sessionUsage.test.ts new file mode 100644 index 00000000..34832ec0 --- /dev/null +++ b/src/lib/sessionUsage.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { mergeSessionUsage, resetProcessCounters } from "./sessionUsage"; + +describe("mergeSessionUsage", () => { + it("takes cumulative cost and API time as deltas and sums tokens", () => { + let usage = mergeSessionUsage(undefined, { + processCostUsd: 0.01, + processApiMs: 1_700, + turnTokens: { + input: 10, + output: 62, + cacheRead: 15_118, + cacheWrite: 4_522, + }, + }); + usage = mergeSessionUsage(usage, { + processCostUsd: 0.013, + processApiMs: 2_750, + turnTokens: { input: 10, output: 31, cacheRead: 19_640, cacheWrite: 139 }, + }); + expect(usage).toEqual({ + costUsd: 0.013, + apiMs: 2_750, + tokens: { input: 20, output: 93, cacheRead: 34_758, cacheWrite: 4_661 }, + lastProcessCostUsd: 0.013, + lastProcessApiMs: 2_750, + }); + }); + + it("adds a new process's counters on top after a reset", () => { + let usage = mergeSessionUsage(undefined, { + processCostUsd: 0.5, + processApiMs: 9_000, + }); + usage = mergeSessionUsage(resetProcessCounters(usage), { + processCostUsd: 0.2, + processApiMs: 1_000, + }); + expect(usage.costUsd).toBeCloseTo(0.7); + expect(usage.apiMs).toBe(10_000); + expect(usage.lastProcessCostUsd).toBe(0.2); + }); + + it("leaves untouched fields alone when a result omits them", () => { + const usage = mergeSessionUsage( + mergeSessionUsage(undefined, { processCostUsd: 0.3 }), + { turnTokens: { input: 1, output: 2, cacheRead: 3, cacheWrite: 4 } }, + ); + expect(usage.costUsd).toBe(0.3); + expect(usage.apiMs).toBe(0); + expect(usage.tokens).toEqual({ + input: 1, + output: 2, + cacheRead: 3, + cacheWrite: 4, + }); + }); +}); diff --git a/src/lib/sessionUsage.ts b/src/lib/sessionUsage.ts new file mode 100644 index 00000000..e4560794 --- /dev/null +++ b/src/lib/sessionUsage.ts @@ -0,0 +1,55 @@ +export type UsageTokens = { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; +}; + +export type TurnUsage = { + processCostUsd?: number; + processApiMs?: number; + turnTokens?: UsageTokens; +}; + +export type SessionUsage = { + costUsd: number; + apiMs: number; + tokens: UsageTokens; + lastProcessCostUsd: number; + lastProcessApiMs: number; +}; + +const EMPTY_USAGE: SessionUsage = { + costUsd: 0, + apiMs: 0, + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + lastProcessCostUsd: 0, + lastProcessApiMs: 0, +}; + +export function mergeSessionUsage( + previous: SessionUsage | undefined, + turn: TurnUsage, +): SessionUsage { + const base = previous ?? EMPTY_USAGE; + const next = { ...base, tokens: { ...base.tokens } }; + if (turn.processCostUsd != null) { + next.costUsd += turn.processCostUsd - base.lastProcessCostUsd; + next.lastProcessCostUsd = turn.processCostUsd; + } + if (turn.processApiMs != null) { + next.apiMs += turn.processApiMs - base.lastProcessApiMs; + next.lastProcessApiMs = turn.processApiMs; + } + if (turn.turnTokens) { + next.tokens.input += turn.turnTokens.input; + next.tokens.output += turn.turnTokens.output; + next.tokens.cacheRead += turn.turnTokens.cacheRead; + next.tokens.cacheWrite += turn.turnTokens.cacheWrite; + } + return next; +} + +export function resetProcessCounters(usage: SessionUsage): SessionUsage { + return { ...usage, lastProcessCostUsd: 0, lastProcessApiMs: 0 }; +} diff --git a/src/lib/skills.test.ts b/src/lib/skills.test.ts index d1b5e475..fe103dbe 100644 --- a/src/lib/skills.test.ts +++ b/src/lib/skills.test.ts @@ -18,8 +18,10 @@ import { injectSkillPrompt, isValidSkillName, isNativeCommandPrompt, + isStandaloneCommand, mergeCatalog, rankSkills, + readSkillBody, replaceSlashToken, skillNamesInText, skillTextParts, @@ -27,6 +29,7 @@ import { slugSkillName, type Skill, } from "./skills"; +import { USAGE_COMMAND } from "./usage"; describe("native command composer behavior", () => { it("filters commands by alias and inserts their invocation with arguments intact", () => { @@ -287,6 +290,23 @@ describe("mergeCatalog", () => { }); }); +describe("isStandaloneCommand", () => { + it("matches only the bare command", () => { + expect(isStandaloneCommand("/usage", "usage")).toBe(true); + expect(isStandaloneCommand(" /USAGE\n", "usage")).toBe(true); + expect(isStandaloneCommand("/usage now", "usage")).toBe(false); + expect(isStandaloneCommand("see /usage", "usage")).toBe(false); + expect(isStandaloneCommand("/usages", "usage")).toBe(false); + }); +}); + +describe("readSkillBody", () => { + it("returns an empty body for app-handled builtins", async () => { + await expect(readSkillBody(USAGE_COMMAND)).resolves.toBe(""); + await expect(readSkillBody(BUILTIN_CREATE_SKILL)).resolves.not.toBe(""); + }); +}); + describe("rankSkills", () => { it("puts create-skill first when the query is empty", () => { const ranked = rankSkills([native, review, BUILTIN_CREATE_SKILL], ""); diff --git a/src/lib/skills.ts b/src/lib/skills.ts index 62c479e8..4567607a 100644 --- a/src/lib/skills.ts +++ b/src/lib/skills.ts @@ -146,6 +146,11 @@ export function hasNativeCommands(harness: HarnessId): boolean { return !!getHarness(harness)?.commands; } +/** `/name` alone on the line, so ordinary prompt text is never consumed. */ +export function isStandaloneCommand(text: string, name: string): boolean { + return new RegExp(`^\\s*/${name}\\s*$`, "i").test(text); +} + export function isNativeCommandPrompt( text: string, harness: HarnessId, @@ -558,7 +563,9 @@ export function warmNativeSkills( export async function readSkillBody( skill: FileSkill | BuiltinSkill, ): Promise { - if (skill.kind === "builtin") return CREATE_SKILL_BODY; + if (skill.kind === "builtin") { + return skill.name === CREATE_SKILL_NAME ? CREATE_SKILL_BODY : ""; + } try { return await readTextFile(skill.path); } catch { diff --git a/src/lib/usage.test.ts b/src/lib/usage.test.ts new file mode 100644 index 00000000..63229f17 --- /dev/null +++ b/src/lib/usage.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from "vitest"; +import { + formatResetLine, + formatSessionDuration, + formatUsageReport, + isUsageCommand, + turnTimeMs, + usageBar, +} from "./usage"; + +const KIEV = "Europe/Kiev"; +const NOW_SEP13_2PM_KIEV = Date.parse("2026-09-13T11:00:00Z"); +const RESET_SEP13_7_50PM_KIEV = Date.parse("2026-09-13T16:50:00Z"); +const RESET_SEP15_11AM_KIEV = Date.parse("2026-09-15T08:00:00Z"); +const RESET_SEP14_2_30AM_KIEV = Date.parse("2026-09-13T23:30:00Z"); + +describe("usage command", () => { + it("matches a standalone /usage command", () => { + expect(isUsageCommand("/usage")).toBe(true); + expect(isUsageCommand(" /USAGE\n")).toBe(true); + }); + + it("does not consume ordinary prompt text", () => { + expect(isUsageCommand("/usage now")).toBe(false); + expect(isUsageCommand("see /usage")).toBe(false); + expect(isUsageCommand("/usages")).toBe(false); + }); +}); + +describe("usageBar", () => { + it("draws one block per 2% and a half block for an odd percent", () => { + expect(usageBar(3)).toBe(`█▌${" ".repeat(48)} 3% used`); + expect(usageBar(27)).toBe(`${"█".repeat(13)}▌${" ".repeat(36)} 27% used`); + expect(usageBar(48)).toBe(`${"█".repeat(24)}${" ".repeat(26)} 48% used`); + }); + + it("keeps the bar 50 cells wide at the edges", () => { + expect(usageBar(0)).toBe(`${" ".repeat(50)} 0% used`); + expect(usageBar(99)).toBe(`${"█".repeat(49)}▌ 99% used`); + expect(usageBar(100)).toBe(`${"█".repeat(50)} 100% used`); + expect(usageBar(140)).toBe(`${"█".repeat(50)} 100% used`); + expect(usageBar(Number.NaN)).toBe(`${" ".repeat(50)} 0% used`); + }); + + it("rounds fractional percentages before drawing", () => { + expect(usageBar(58.2)).toBe(`${"█".repeat(29)}${" ".repeat(21)} 58% used`); + }); +}); + +describe("formatResetLine", () => { + const options = { now: NOW_SEP13_2PM_KIEV, timeZone: KIEV }; + + it("prints only the clock time for a same-day reset", () => { + expect(formatResetLine(RESET_SEP13_7_50PM_KIEV, options)).toBe( + "Resets 7:50pm (Europe/Kiev)", + ); + }); + + it("prints the date and drops :00 for a later day", () => { + expect(formatResetLine(RESET_SEP15_11AM_KIEV, options)).toBe( + "Resets Sep 15 at 11am (Europe/Kiev)", + ); + }); + + it("uses the zone's calendar day, not UTC's", () => { + expect(formatResetLine(RESET_SEP14_2_30AM_KIEV, options)).toBe( + "Resets Sep 14 at 2:30am (Europe/Kiev)", + ); + }); + + it("says so when the reset time is unknown", () => { + expect(formatResetLine(null, options)).toBe("Resets at an unknown time"); + }); +}); + +describe("formatSessionDuration", () => { + it("floors to whole seconds and omits empty leading units", () => { + expect(formatSessionDuration(0)).toBe("0s"); + expect(formatSessionDuration(999)).toBe("0s"); + expect(formatSessionDuration(123_000)).toBe("2m 3s"); + expect(formatSessionDuration(3_723_000)).toBe("1h 2m 3s"); + expect(formatSessionDuration(-5)).toBe("0s"); + }); +}); + +describe("turnTimeMs", () => { + it("sums finished turns and counts an in-flight turn to now", () => { + const blocks = [ + { id: "u1", role: "user" as const, text: "a", durationMs: 100_000 }, + { id: "a1", role: "assistant" as const, text: "b" }, + { + id: "u2", + role: "user" as const, + text: "c", + startedAt: NOW_SEP13_2PM_KIEV - 23_000, + }, + { id: "s1", role: "system" as const, text: "d" }, + ]; + expect(turnTimeMs(blocks, NOW_SEP13_2PM_KIEV)).toBe(123_000); + }); + + it("ignores user blocks without timing", () => { + expect( + turnTimeMs([{ id: "u", role: "user", text: "x" }], NOW_SEP13_2PM_KIEV), + ).toBe(0); + }); +}); + +describe("formatUsageReport", () => { + const options = { now: NOW_SEP13_2PM_KIEV, timeZone: KIEV }; + + it("renders the four sections in CLI layout", () => { + const report = formatUsageReport( + { + session: { + blocks: [ + { + id: "u1", + role: "user", + text: "hi", + durationMs: 123_000, + }, + ], + usage: { + costUsd: 0, + apiMs: 0, + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + lastProcessCostUsd: 0, + lastProcessApiMs: 0, + }, + }, + limits: { + error: null, + session: { + usedPercent: 3, + windowMinutes: 300, + resetsAt: RESET_SEP13_7_50PM_KIEV, + }, + weekly: { + usedPercent: 27, + windowMinutes: 10_080, + resetsAt: RESET_SEP15_11AM_KIEV, + }, + weeklyByModel: [ + { + label: "Fable", + window: { + usedPercent: 48, + windowMinutes: 10_080, + resetsAt: RESET_SEP15_11AM_KIEV, + }, + }, + ], + }, + }, + options, + ); + expect(report).toBe( + [ + "/usage", + "", + "Session", + "Total cost: $0.0000", + "Total duration (API): 0s", + "Total duration (wall): 2m 3s", + "Usage: 0 input, 0 output, 0 cache read, 0 cache write", + "", + "Current session", + `█▌${" ".repeat(48)} 3% used`, + "Resets 7:50pm (Europe/Kiev)", + "", + "Current week (all models)", + `${"█".repeat(13)}▌${" ".repeat(36)} 27% used`, + "Resets Sep 15 at 11am (Europe/Kiev)", + "", + "Current week (Fable)", + `${"█".repeat(24)}${" ".repeat(26)} 48% used`, + "Resets Sep 15 at 11am (Europe/Kiev)", + ].join("\n"), + ); + }); + + it("renders zero values for numbers no turn has reported yet", () => { + const report = formatUsageReport( + { + session: { + blocks: [{ id: "u1", role: "user", text: "hi", durationMs: 5_000 }], + }, + limits: { error: null, session: null, weekly: null }, + }, + options, + ); + expect(report).toBe( + [ + "/usage", + "", + "Session", + "Total cost: $0.0000", + "Total duration (API): 0s", + "Total duration (wall): 5s", + "Usage: 0 input, 0 output, 0 cache read, 0 cache write", + "", + "Rate limits unavailable: no data", + ].join("\n"), + ); + }); + + it("surfaces the fetch error when no window came back", () => { + const report = formatUsageReport( + { + session: { blocks: [] }, + limits: { error: "Claude not signed in", session: null, weekly: null }, + }, + options, + ); + expect( + report.endsWith("Rate limits unavailable: Claude not signed in"), + ).toBe(true); + }); + + it("omits a window section when that window is missing", () => { + const report = formatUsageReport( + { + session: { blocks: [] }, + limits: { + error: null, + session: { usedPercent: 6, windowMinutes: 300, resetsAt: null }, + weekly: null, + weeklyByModel: [], + }, + }, + options, + ); + expect(report).toContain("Current session"); + expect(report).not.toContain("Current week"); + expect(report).not.toContain("Rate limits unavailable"); + }); + + it("groups large token counts", () => { + const report = formatUsageReport( + { + session: { + blocks: [], + usage: { + costUsd: 1.5, + apiMs: 0, + tokens: { + input: 1234567, + output: 89, + cacheRead: 0, + cacheWrite: 1000, + }, + lastProcessCostUsd: 1.5, + lastProcessApiMs: 0, + }, + }, + limits: { error: null, session: null, weekly: null }, + }, + options, + ); + expect(report).toContain( + "Usage: 1,234,567 input, 89 output, 0 cache read, 1,000 cache write", + ); + }); +}); diff --git a/src/lib/usage.ts b/src/lib/usage.ts new file mode 100644 index 00000000..b05d015a --- /dev/null +++ b/src/lib/usage.ts @@ -0,0 +1,210 @@ +import type { + ProviderRateLimits, + RateLimitWindow, + ScopedRateLimitWindow, +} from "./rateLimits"; +import type { Block, Session } from "./session"; +import type { UsageTokens } from "./sessionUsage"; +import { isStandaloneCommand, type BuiltinSkill } from "./skills"; + +export const USAGE_COMMAND: BuiltinSkill = { + kind: "builtin", + name: "usage", + invocation: "usage", + description: "Show session totals and Claude rate-limit windows.", + scope: "builtin", + source: "monocode", +}; + +export const USAGE_SNAPSHOT_MAX_AGE_MS = 30_000; + +export function isUsageCommand(text: string): boolean { + return isStandaloneCommand(text, USAGE_COMMAND.name); +} + +export type UsageReportLimits = Pick< + ProviderRateLimits, + "session" | "weekly" | "weeklyByModel" | "error" +>; + +export type UsageReportInput = { + session: Pick; + limits: UsageReportLimits; +}; + +export type UsageReportOptions = { + now: number; + timeZone: string; +}; + +const BAR_CELLS = 50; +const NO_TOKENS: UsageTokens = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +export function turnTimeMs(blocks: readonly Block[], now: number): number { + let total = 0; + for (const block of blocks) { + if (block.role !== "user") continue; + if (block.durationMs != null) total += block.durationMs; + else if (block.startedAt != null) + total += Math.max(0, now - block.startedAt); + } + return total; +} + +/** 50-cell bar: one `█` per 2%, `▌` for an odd percent, then "N% used". */ +export function usageBar(usedPercent: number): string { + const percent = Math.min( + 100, + Math.max(0, Math.round(Number.isFinite(usedPercent) ? usedPercent : 0)), + ); + const full = Math.floor(percent / 2); + const half = percent % 2 === 1; + const bar = "█".repeat(full) + (half ? "▌" : ""); + return `${bar.padEnd(BAR_CELLS)} ${percent}% used`; +} + +export function formatResetLine( + resetsAt: number | null, + options: UsageReportOptions, +): string { + if (resetsAt == null) return "Resets at an unknown time"; + const { now, timeZone } = options; + const time = clockTime(resetsAt, timeZone); + const zone = ` (${timeZone})`; + if (dayKey(resetsAt, timeZone) === dayKey(now, timeZone)) { + return `Resets ${time}${zone}`; + } + const date = new Intl.DateTimeFormat("en-US", { + timeZone, + month: "short", + day: "numeric", + }).format(resetsAt); + return `Resets ${date} at ${time}${zone}`; +} + +export function formatSessionDuration(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} + +export function formatUsageReport( + input: UsageReportInput, + options: UsageReportOptions, +): string { + const sections: string[][] = [ + [`/${USAGE_COMMAND.name}`], + sessionSection(input.session, options.now), + ]; + const { limits } = input; + if (limits.session) { + sections.push(windowSection("Current session", limits.session, options)); + } + if (limits.weekly) { + sections.push( + windowSection("Current week (all models)", limits.weekly, options), + ); + } + for (const scoped of limits.weeklyByModel ?? []) { + sections.push(scopedWindowSection(scoped, options)); + } + if (!limits.session && !limits.weekly && !limits.weeklyByModel?.length) { + sections.push([`Rate limits unavailable: ${limits.error ?? "no data"}`]); + } + return sections.map((lines) => lines.join("\n")).join("\n\n"); +} + +function sessionSection( + session: Pick, + now: number, +): string[] { + const usage = session.usage; + const tokens = usage?.tokens ?? NO_TOKENS; + return [ + "Session", + sessionLine("Total cost:", `$${(usage?.costUsd ?? 0).toFixed(4)}`), + sessionLine( + "Total duration (API):", + formatSessionDuration(usage?.apiMs ?? 0), + ), + sessionLine( + "Total duration (wall):", + formatSessionDuration(turnTimeMs(session.blocks, now)), + ), + sessionLine( + "Usage:", + [ + `${formatCount(tokens.input)} input`, + `${formatCount(tokens.output)} output`, + `${formatCount(tokens.cacheRead)} cache read`, + `${formatCount(tokens.cacheWrite)} cache write`, + ].join(", "), + ), + ]; +} + +const SESSION_LABEL_WIDTH = "Total duration (wall):".length + 1; + +function sessionLine(label: string, value: string): string { + return `${label.padEnd(SESSION_LABEL_WIDTH)}${value}`; +} + +function windowSection( + title: string, + window: RateLimitWindow, + options: UsageReportOptions, +): string[] { + return [ + title, + usageBar(window.usedPercent), + formatResetLine(window.resetsAt, options), + ]; +} + +function scopedWindowSection( + scoped: ScopedRateLimitWindow, + options: UsageReportOptions, +): string[] { + return windowSection( + `Current week (${scoped.label})`, + scoped.window, + options, + ); +} + +function formatCount(value: number): string { + return Math.round(value).toLocaleString("en-US"); +} + +function clockTime(at: number, timeZone: string): string { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + hour: "numeric", + minute: "2-digit", + hour12: true, + }).formatToParts(at); + const part = (type: string) => + parts.find((p) => p.type === type)?.value ?? ""; + const hour = part("hour"); + const minute = part("minute"); + const period = part("dayPeriod").toLowerCase(); + return minute === "00" ? `${hour}${period}` : `${hour}:${minute}${period}`; +} + +function dayKey(at: number, timeZone: string): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(at); +} From 62e7926dc12a148ef5642b98c46b66570400e153 Mon Sep 17 00:00:00 2001 From: Andrii Kartava Date: Sun, 13 Sep 2026 18:51:13 +0300 Subject: [PATCH 2/2] Address review feedback on the `/usage` command --- src/App.tsx | 8 +++- src/chrome/Composer.tsx | 2 +- src/lib/rateLimits.test.ts | 38 +++++++++++++++---- src/lib/rateLimits.ts | 8 +++- src/lib/rateLimitsFetch.test.ts | 66 +++++++++++++++++++++++++++++++++ src/lib/rateLimitsFetch.ts | 12 ++++-- 6 files changed, 119 insertions(+), 15 deletions(-) create mode 100644 src/lib/rateLimitsFetch.test.ts diff --git a/src/App.tsx b/src/App.tsx index fac2d75a..b12d2e18 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3926,7 +3926,13 @@ export default function App({ void fetchClaudeRateLimits({ maxAgeMs: USAGE_SNAPSHOT_MAX_AGE_MS }).then( (limits) => { const session = sessionsRef.current.find((s) => s.id === sessionId); - if (!session || removingSessionIds.current.has(sessionId)) return; + if ( + !session || + session.harness !== "claude" || + removingSessionIds.current.has(sessionId) + ) { + return; + } enqueueHarnessEvent(sessionId, { type: "status", text: formatUsageReport( diff --git a/src/chrome/Composer.tsx b/src/chrome/Composer.tsx index 378578e7..cb9d3ef6 100644 --- a/src/chrome/Composer.tsx +++ b/src/chrome/Composer.tsx @@ -532,7 +532,7 @@ export function Composer({ skill.kind === "native" || (skill.name !== PLAN_COMMAND.name && skill.name !== COMPACT_COMMAND.name && - skill.name !== USAGE_COMMAND.name && + (harness !== "claude" || skill.name !== USAGE_COMMAND.name) && skill.name !== SESSION_FOLDER_COMMAND.name), ), ], diff --git a/src/lib/rateLimits.test.ts b/src/lib/rateLimits.test.ts index 2c527d81..10af5a0b 100644 --- a/src/lib/rateLimits.test.ts +++ b/src/lib/rateLimits.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { clampUsedPercent, + errorRateLimits, formatRateLimitWindowChipLabel, formatResetCountdown, formatResetDuration, @@ -142,7 +143,10 @@ describe("parseClaudeOAuthUsage", () => { group: "weekly", percent: 49, resets_at: "2026-09-15T08:00:00Z", - scope: { model: { id: null, display_name: "Fable" }, surface: null }, + scope: { + model: { id: null, display_name: "Fable" }, + surface: null, + }, }, { kind: "weekly_scoped", @@ -175,6 +179,26 @@ describe("parseClaudeOAuthUsage", () => { ]); }); + it("keeps a scoped-only snapshot through a later error", () => { + const scopedOnly = parseClaudeOAuthUsage( + JSON.stringify({ + limits: [ + { + kind: "weekly_scoped", + percent: 49, + resets_at: "2026-09-15T08:00:00Z", + scope: { model: { display_name: "Fable" } }, + }, + ], + }), + ); + expect(scopedOnly.session).toBeNull(); + expect(scopedOnly.weeklyByModel).toHaveLength(1); + const failed = errorRateLimits("claude", "boom", scopedOnly); + expect(failed.status).toBe("error"); + expect(failed.weeklyByModel).toEqual(scopedOnly.weeklyByModel); + }); + it("leaves weeklyByModel empty without a limits array", () => { const limits = parseClaudeOAuthUsage( JSON.stringify({ five_hour: { utilization: 1 } }), @@ -319,9 +343,9 @@ describe("shouldFetchRateLimits", () => { error: "Codex CLI not found", }; expect(isRateLimitSnapshotStale(disconnected, now)).toBe(false); - expect( - shouldFetchProvider(disconnected, { visible: true, now }), - ).toBe(false); + expect(shouldFetchProvider(disconnected, { visible: true, now })).toBe( + false, + ); expect( shouldFetchRateLimits({ visible: true, @@ -339,9 +363,9 @@ describe("shouldFetchRateLimits", () => { updatedAt: now - RATE_LIMIT_MIN_REFETCH_MS, error: "Claude not signed in", }; - expect( - shouldFetchProvider(disconnected, { visible: true, now }), - ).toBe(false); + expect(shouldFetchProvider(disconnected, { visible: true, now })).toBe( + false, + ); expect( shouldFetchRateLimits({ visible: true, diff --git a/src/lib/rateLimits.ts b/src/lib/rateLimits.ts index e89a35b4..cf9b9aa8 100644 --- a/src/lib/rateLimits.ts +++ b/src/lib/rateLimits.ts @@ -88,7 +88,7 @@ export function fetchingRateLimits( provider: RateLimitProvider, previous?: ProviderRateLimits | null, ): ProviderRateLimits { - if (previous && (previous.session || previous.weekly)) { + if (previous && hasRateLimitData(previous)) { return { ...previous, status: "fetching" }; } return { @@ -120,7 +120,7 @@ export function errorRateLimits( error: string, previous?: ProviderRateLimits | null, ): ProviderRateLimits { - if (previous && (previous.session || previous.weekly)) { + if (previous && hasRateLimitData(previous)) { return { ...previous, error, @@ -138,6 +138,10 @@ export function errorRateLimits( }; } +function hasRateLimitData(limits: ProviderRateLimits): boolean { + return !!(limits.session || limits.weekly || limits.weeklyByModel?.length); +} + export function clampUsedPercent(value: number): number { if (!Number.isFinite(value)) return 0; return Math.min(100, Math.max(0, value)); diff --git a/src/lib/rateLimitsFetch.test.ts b/src/lib/rateLimitsFetch.test.ts new file mode 100644 index 00000000..ecee6c63 --- /dev/null +++ b/src/lib/rateLimitsFetch.test.ts @@ -0,0 +1,66 @@ +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); +vi.mock("./fs", () => ({ homeDir: vi.fn() })); +vi.mock("./harness/child", () => ({ + killChild: vi.fn(), + resolveCodexBinary: vi.fn(), + spawnChild: vi.fn(), + unwatchChild: vi.fn(), + watchChild: vi.fn(), +})); + +import { invoke } from "@tauri-apps/api/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchClaudeRateLimits } from "./rateLimitsFetch"; + +const mockedInvoke = vi.mocked(invoke); +const OK_BODY = JSON.stringify({ + five_hour: { utilization: 6, resets_at: "2026-09-13T16:50:00Z" }, + seven_day: { utilization: 28, resets_at: "2026-09-15T08:00:00Z" }, +}); + +let now = 1_000_000; + +beforeEach(() => { + vi.spyOn(Date, "now").mockImplementation(() => now); + mockedInvoke.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("fetchClaudeRateLimits", () => { + it("reuses a snapshot younger than maxAgeMs and refetches after", async () => { + mockedInvoke.mockResolvedValue({ status: "ok", body: OK_BODY }); + await fetchClaudeRateLimits(); + now += 10_000; + await fetchClaudeRateLimits({ maxAgeMs: 30_000 }); + expect(mockedInvoke).toHaveBeenCalledTimes(1); + now += 30_000; + await fetchClaudeRateLimits({ maxAgeMs: 30_000 }); + expect(mockedInvoke).toHaveBeenCalledTimes(2); + }); + + it("expires an unavailable snapshot so a later sign-in is seen", async () => { + mockedInvoke.mockResolvedValueOnce({ + status: "unavailable", + error: "Claude not signed in", + }); + expect((await fetchClaudeRateLimits()).status).toBe("unavailable"); + now += 60_000; + mockedInvoke.mockResolvedValueOnce({ status: "ok", body: OK_BODY }); + const next = await fetchClaudeRateLimits({ maxAgeMs: 30_000 }); + expect(next.status).toBe("ok"); + expect(next.session?.usedPercent).toBe(6); + }); + + it("keeps the last windows when a fetch fails", async () => { + mockedInvoke.mockResolvedValueOnce({ status: "ok", body: OK_BODY }); + await fetchClaudeRateLimits(); + mockedInvoke.mockRejectedValueOnce(new Error("offline")); + const failed = await fetchClaudeRateLimits(); + expect(failed.status).toBe("error"); + expect(failed.error).toBe("offline"); + expect(failed.weekly?.usedPercent).toBe(28); + }); +}); diff --git a/src/lib/rateLimitsFetch.ts b/src/lib/rateLimitsFetch.ts index 6d7e1e7d..a6d3a8b4 100644 --- a/src/lib/rateLimitsFetch.ts +++ b/src/lib/rateLimitsFetch.ts @@ -2,7 +2,6 @@ import { invoke } from "@tauri-apps/api/core"; import { homeDir } from "./fs"; import { errorRateLimits, - isRateLimitSnapshotStale, parseClaudeOAuthUsage, parseCodexRateLimits, unavailableRateLimits, @@ -38,15 +37,18 @@ export async function fetchClaudeRateLimits(options?: { if ( options?.maxAgeMs != null && lastClaudeSnapshot && - !isRateLimitSnapshotStale(lastClaudeSnapshot, Date.now(), options.maxAgeMs) + lastClaudeSnapshot.updatedAt > 0 && + Date.now() - lastClaudeSnapshot.updatedAt < options.maxAgeMs ) { return lastClaudeSnapshot; } - lastClaudeSnapshot = await fetchClaudeRateLimitsNow(); + lastClaudeSnapshot = await fetchClaudeRateLimitsNow(lastClaudeSnapshot); return lastClaudeSnapshot; } -async function fetchClaudeRateLimitsNow(): Promise { +async function fetchClaudeRateLimitsNow( + previous: ProviderRateLimits | null, +): Promise { try { const result = await invoke("fetch_claude_usage"); if (result.status === "ok" && result.body) { @@ -61,11 +63,13 @@ async function fetchClaudeRateLimitsNow(): Promise { return errorRateLimits( "claude", result.error?.trim() || "Claude usage unavailable", + previous, ); } catch (error) { return errorRateLimits( "claude", error instanceof Error ? error.message : "Claude usage unavailable", + previous, ); } }