diff --git a/src/App.tsx b/src/App.tsx index 4acd2244..9e0e0c4c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -342,6 +342,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, @@ -4467,6 +4473,36 @@ 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 || + session.harness !== "claude" || + 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, @@ -4514,6 +4550,10 @@ export default function App({ if (removingSessionIds.current.has(sessionId)) return false; const storedCurrent = sessionsRef.current.find((s) => s.id === sessionId); if (!storedCurrent) return false; + if (storedCurrent.harness === "claude" && isUsageCommand(text)) { + showUsage(sessionId); + return true; + } const current = options?.buildTarget ? withPlanBuildTarget(storedCurrent, options.buildTarget) : storedCurrent; @@ -5198,6 +5238,7 @@ export default function App({ dismissNoticesForContinuedSession, enqueueHarnessEvent, flushHarnessEvents, + showUsage, ], ); diff --git a/src/chrome/Composer.tsx b/src/chrome/Composer.tsx index 493f0002..71acd710 100644 --- a/src/chrome/Composer.tsx +++ b/src/chrome/Composer.tsx @@ -125,6 +125,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, @@ -527,15 +528,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 && + (harness !== "claude" || 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 861c2d34..bf73b0e8 100644 --- a/src/lib/harness/apply.test.ts +++ b/src/lib/harness/apply.test.ts @@ -641,6 +641,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("applyHarnessEvent turn metrics", () => { it("attaches provider metrics to the latest user turn", () => { let session = appendUser(newSession("claude", "/repo"), "Explain this"); diff --git a/src/lib/harness/apply.ts b/src/lib/harness/apply.ts index 6721f735..a4f40874 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 "turn.metrics": return mergeTurnMetrics(session, event); case "tasks.updated": diff --git a/src/lib/harness/claude.ts b/src/lib/harness/claude.ts index 0ac23949..16875602 100644 --- a/src/lib/harness/claude.ts +++ b/src/lib/harness/claude.ts @@ -17,6 +17,7 @@ import { assistantThinkingBlocks, assistantToolUses, contextFromResult, + usageFromResult, contextUsedFromAssistant, turnMetricsFromResult, buildClaudeSpawnArgs, @@ -759,6 +760,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 metrics = turnMetricsFromResult(rec); if (metrics) live.onEvent({ type: "turn.metrics", ...metrics }); 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 6b709275..26a2b792 100644 --- a/src/lib/harness/claudeProtocol.test.ts +++ b/src/lib/harness/claudeProtocol.test.ts @@ -10,6 +10,7 @@ import { buildClaudeUserMessage, contextFromResult, contextUsedFromAssistant, + usageFromResult, extractExitPlanModePlan, isClaudeInitMessage, isSubagentMessage, @@ -617,6 +618,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 e175ea49..ca3bec02 100644 --- a/src/lib/harness/claudeProtocol.ts +++ b/src/lib/harness/claudeProtocol.ts @@ -5,6 +5,7 @@ import type { ToolPreview, TurnMetrics, } from "../session"; +import type { TurnUsage } from "../sessionUsage"; import { attachmentPathText } from "../attachments"; import { isTaskListToolName, taskListFromToolInput } from "../taskList"; import { @@ -1090,3 +1091,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 562372e3..2fcbc807 100644 --- a/src/lib/harness/types.ts +++ b/src/lib/harness/types.ts @@ -8,6 +8,7 @@ import type { TurnIntent, TurnMetrics, } from "../session"; +import type { TurnUsage } from "../sessionUsage"; import type { UserQuestion } from "../userQuestion"; export type HarnessEvent = @@ -118,6 +119,7 @@ export type HarnessEvent = } /** Context-window level after the harness's latest request. */ | { type: "context"; used?: number; window?: number } + | ({ type: "usage" } & TurnUsage) /** Provider token accounting for the active user turn. */ | ({ type: "turn.metrics" } & TurnMetrics); diff --git a/src/lib/rateLimits.test.ts b/src/lib/rateLimits.test.ts index ba39bf1f..f2716cb7 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, @@ -129,6 +130,82 @@ 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("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 } }), + ); + 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 59cd7e0d..d865b15a 100644 --- a/src/lib/rateLimits.ts +++ b/src/lib/rateLimits.ts @@ -1,3 +1,4 @@ +import { stringField as optionalStringField } from "./harness/claudeProtocol"; import { asRecord } from "./harness/codexProtocol"; export type RateLimitProvider = "claude" | "codex"; @@ -34,6 +35,7 @@ export type ProviderRateLimits = { provider: RateLimitProvider; session: RateLimitWindow | null; weekly: RateLimitWindow | null; + weeklyByModel?: ScopedRateLimitWindow[]; /** Codex-only banked rate-limit reset rewards, when supplied by app-server. */ resetCredits: RateLimitResetCredits | null; updatedAt: number; @@ -41,6 +43,8 @@ export type ProviderRateLimits = { status: RateLimitStatus; }; +export type ScopedRateLimitWindow = { label: string; window: RateLimitWindow }; + export const SESSION_WINDOW_MINUTES = 300; export const WEEKLY_WINDOW_MINUTES = 10_080; @@ -103,10 +107,7 @@ export function fetchingRateLimits( provider: RateLimitProvider, previous?: ProviderRateLimits | null, ): ProviderRateLimits { - if ( - previous && - (previous.session || previous.weekly || previous.resetCredits) - ) { + if (previous && hasRateLimitData(previous)) { return { ...previous, status: "fetching" }; } return { @@ -140,10 +141,7 @@ export function errorRateLimits( error: string, previous?: ProviderRateLimits | null, ): ProviderRateLimits { - if ( - previous && - (previous.session || previous.weekly || previous.resetCredits) - ) { + if (previous && hasRateLimitData(previous)) { return { ...previous, error, @@ -162,6 +160,15 @@ export function errorRateLimits( }; } +function hasRateLimitData(limits: ProviderRateLimits): boolean { + return !!( + limits.session || + limits.weekly || + limits.weeklyByModel?.length || + limits.resetCredits + ); +} + export function clampUsedPercent(value: number): number { if (!Number.isFinite(value)) return 0; return Math.min(100, Math.max(0, value)); @@ -299,6 +306,7 @@ 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), resetCredits: null, updatedAt: Date.now(), error: null, @@ -306,6 +314,26 @@ export function parseClaudeOAuthUsage(body: string): ProviderRateLimits { }; } +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 = + optionalStringField(model, "display_name") ?? + optionalStringField(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.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 1b9d0f05..6ec93ca2 100644 --- a/src/lib/rateLimitsFetch.ts +++ b/src/lib/rateLimitsFetch.ts @@ -31,16 +31,31 @@ 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 && + lastClaudeSnapshot.updatedAt > 0 && + Date.now() - lastClaudeSnapshot.updatedAt < options.maxAgeMs + ) { + return lastClaudeSnapshot; + } + lastClaudeSnapshot = await fetchClaudeRateLimitsNow(lastClaudeSnapshot); + return lastClaudeSnapshot; +} + +async function fetchClaudeRateLimitsNow( + previous: ProviderRateLimits | null, +): 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( @@ -51,11 +66,13 @@ export async function fetchClaudeRateLimits(): 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, ); } } diff --git a/src/lib/session.ts b/src/lib/session.ts index 638c6a86..4ee3438f 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"; @@ -319,6 +320,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); +}