diff --git a/src/quota/schemas.ts b/src/quota/schemas.ts index 3eef164..b7efb6e 100644 --- a/src/quota/schemas.ts +++ b/src/quota/schemas.ts @@ -27,6 +27,8 @@ const WindowSchema = z windowSeconds: z.number().optional(), duration_seconds: z.number().optional(), durationSeconds: z.number().optional(), + limit_window_seconds: z.number().optional(), + limitWindowSeconds: z.number().optional(), // Duration in minutes (converted to seconds) window_minutes: z.number().optional(), windowMinutes: z.number().optional(), @@ -37,11 +39,11 @@ const WindowSchema = z usedPercent: z.number().optional(), percent: z.number().optional(), usage_percent: z.number().optional(), - // Reset time (ISO string) - resets_at: z.string().optional(), - resetsAt: z.string().optional(), - reset_at: z.string().optional(), - resetAt: z.string().optional(), + // Reset time — may be an ISO string OR a Unix timestamp (number, seconds) + resets_at: z.union([z.string(), z.number()]).optional(), + resetsAt: z.union([z.string(), z.number()]).optional(), + reset_at: z.union([z.string(), z.number()]).optional(), + resetAt: z.union([z.string(), z.number()]).optional(), // Reset after (seconds) reset_after_seconds: z.number().optional(), resetAfterSeconds: z.number().optional(), @@ -75,6 +77,16 @@ const WhamResponseSchema = z rate_limits: z.array(WindowSchema).optional(), rateLimits: z.array(WindowSchema).optional(), limits: z.array(WindowSchema).optional(), + // rate_limit object with primary_window / secondary_window (singular) + rate_limit: z + .object({ + primary_window: WindowSchema.nullable().optional(), + primaryWindow: WindowSchema.nullable().optional(), + secondary_window: WindowSchema.nullable().optional(), + secondaryWindow: WindowSchema.nullable().optional(), + }) + .passthrough() + .optional(), // Plan type plan_type: z.string().optional(), planType: z.string().optional(), @@ -103,7 +115,13 @@ export interface WhamParseResult { */ function extractDurationSeconds(w: z.infer): number | null { // Direct seconds fields - const secs = w.window_seconds ?? w.windowSeconds ?? w.duration_seconds ?? w.durationSeconds; + const secs = + w.window_seconds ?? + w.windowSeconds ?? + w.duration_seconds ?? + w.durationSeconds ?? + w.limit_window_seconds ?? + w.limitWindowSeconds; if (typeof secs === "number" && secs > 0) return secs; // Minutes fields — convert to seconds @@ -125,13 +143,19 @@ function extractUsedPercent(w: z.infer): number { } /** - * Extract reset time (ISO string) from a window object. + * Extract reset time from a window object. + * Accepts either an ISO string or a Unix timestamp (number, in seconds). + * Returns an ISO string or null. */ function extractResetsAt(w: z.infer): string | null { const val = w.resets_at ?? w.resetsAt ?? w.reset_at ?? w.resetAt; if (typeof val === "string" && val.length > 0) { return val; } + if (typeof val === "number" && Number.isFinite(val) && val > 0) { + // Unix timestamp in seconds → ISO string + return new Date(val * 1000).toISOString(); + } return null; } @@ -186,6 +210,22 @@ export function parseWhamResponse(raw: unknown): WhamParseResult { const windows = parseWindowsArray(rawWindows); + // Also extract windows from rate_limit.primary_window / secondary_window + // (singular objects, not arrays). The real wham endpoint uses this shape. + const rateLimit = data.rate_limit; + if (rateLimit) { + const primary = rateLimit.primary_window ?? rateLimit.primaryWindow; + if (primary) { + const parsedPrimary = parseSingleWindow(primary); + if (parsedPrimary) windows.push(parsedPrimary); + } + const secondary = rateLimit.secondary_window ?? rateLimit.secondaryWindow; + if (secondary) { + const parsedSecondary = parseSingleWindow(secondary); + if (parsedSecondary) windows.push(parsedSecondary); + } + } + // Extract plan type. const planType = data.plan_type ?? data.planType ?? data.plan ?? data.subscription ?? null; const normalizedPlanType = typeof planType === "string" ? planType : null; @@ -202,6 +242,17 @@ export function parseWhamResponse(raw: unknown): WhamParseResult { }; } +/** + * Parse a single raw window object into a normalized UsageWindow. + * Returns null if the window fails schema validation. + */ +function parseSingleWindow(item: unknown): UsageWindow | null { + if (item === null || item === undefined) return null; + const parsed = WindowSchema.safeParse(item); + if (!parsed.success) return null; + return windowFromParsed(parsed.data); +} + /** * Parse an array of raw window objects into normalized UsageWindow[]. */ @@ -210,31 +261,39 @@ function parseWindowsArray(arr: unknown[]): UsageWindow[] { for (const item of arr) { const parsed = WindowSchema.safeParse(item); if (!parsed.success) continue; - - const durationSeconds = extractDurationSeconds(parsed.data); - if (durationSeconds === null) { - // Window without duration info — classify as unknown. - result.push({ - kind: "unknown", - usedPercent: extractUsedPercent(parsed.data), - windowSeconds: 0, - resetsAt: extractResetsAt(parsed.data), - resetAfterSeconds: extractResetAfterSeconds(parsed.data), - }); - continue; - } - - result.push({ - kind: identifyWindow(durationSeconds), - usedPercent: extractUsedPercent(parsed.data), - windowSeconds: durationSeconds, - resetsAt: extractResetsAt(parsed.data), - resetAfterSeconds: extractResetAfterSeconds(parsed.data), - }); + const w = windowFromParsed(parsed.data); + if (w) result.push(w); } return result; } +/** + * Build a UsageWindow from a successfully-parsed window object. + * Returns null only if the window fails internal validation (shouldn't + * happen after a successful Zod parse, but defensive). + */ +function windowFromParsed(data: z.infer): UsageWindow | null { + const durationSeconds = extractDurationSeconds(data); + if (durationSeconds === null) { + // Window without duration info — classify as unknown. + return { + kind: "unknown", + usedPercent: extractUsedPercent(data), + windowSeconds: 0, + resetsAt: extractResetsAt(data), + resetAfterSeconds: extractResetAfterSeconds(data), + }; + } + + return { + kind: identifyWindow(durationSeconds), + usedPercent: extractUsedPercent(data), + windowSeconds: durationSeconds, + resetsAt: extractResetsAt(data), + resetAfterSeconds: extractResetAfterSeconds(data), + }; +} + /** * Parse the credits object into normalized CreditsInfo. */ diff --git a/src/tui/quota-bar.tsx b/src/tui/quota-bar.tsx index 57127c7..6f6abba 100644 --- a/src/tui/quota-bar.tsx +++ b/src/tui/quota-bar.tsx @@ -3,8 +3,13 @@ * * Used by for the 5-hour and weekly windows. * When `percent` is null (no data), renders a muted "unavailable" label. + * + * IMPORTANT (Solid reactivity): the component function body runs ONCE. + * Reads of `props.window` are done inside JSX expressions / createMemo so + * that the bar re-renders when the window prop changes. */ +import { Show, createMemo } from "solid-js"; import type { UsageWindow } from "../quota/types"; import type { ThemeColors } from "./theme"; @@ -16,22 +21,23 @@ export interface QuotaBarProps { } export function QuotaBar(props: QuotaBarProps) { - const percent = props.window ? Math.round(props.window.usedPercent) : null; - - if (percent === null) { - return {`${props.label} unavailable`}; - } - - const filled = Math.round((percent / 100) * props.barWidth); - const empty = props.barWidth - filled; - const barColor = props.colors.quotaColor(percent); + const percent = createMemo(() => (props.window ? Math.round(props.window.usedPercent) : null)); return ( - - {`${props.label} `} - {"█".repeat(filled)} - {"░".repeat(empty)} - {` ${percent}%`} - + {`${props.label} unavailable`}} + > + + {`${props.label} `} + + {"█".repeat(Math.round(((percent() ?? 0) / 100) * props.barWidth))} + + + {"░".repeat(props.barWidth - Math.round(((percent() ?? 0) / 100) * props.barWidth))} + + {` ${percent()}%`} + + ); } diff --git a/src/tui/sidebar.tsx b/src/tui/sidebar.tsx index 5409c28..d3a621b 100644 --- a/src/tui/sidebar.tsx +++ b/src/tui/sidebar.tsx @@ -10,8 +10,14 @@ * - No active session: "No active session" * - No messages yet: handled by TokenTable * - Quota unavailable: handled by QuotaBar + * + * IMPORTANT (Solid reactivity): the component function body runs ONCE. + * Destructuring props (e.g. `const report = props.report`) captures the + * initial value and never updates. All prop reads that must react to + * changes are done inside JSX expressions or via `createMemo`. */ +import { Show, createMemo } from "solid-js"; import type { Report } from "../report/build"; import { formatResetDuration } from "../report/detailed"; import { QuotaBar } from "./quota-bar"; @@ -25,56 +31,73 @@ export interface SidebarContentProps { } export function SidebarContent(props: SidebarContentProps) { - if (!props.sessionID) { + // Reactive: re-evaluates whenever props.report changes. + const quota = createMemo(() => props.report?.quota ?? null); + const showQuota = createMemo(() => { + const q = quota(); return ( - - No active session - + q !== null && + q.status !== "unavailable" && + q.status !== "unauthenticated" && + q.status !== "unsupported" ); - } - - const report = props.report; - const quota = report?.quota ?? null; - const showQuota = - quota !== null && - quota.status !== "unavailable" && - quota.status !== "unauthenticated" && - quota.status !== "unsupported"; + }); return ( - + No active session + + } > - - Codex Meter - + + + Codex Meter + - {showQuota && quota && ( - - Quota - - - {quota.fiveHour?.resetAfterSeconds != null && ( - - {` resets ${formatResetDuration(quota.fiveHour.resetAfterSeconds)}`} - - )} - - )} + + + Quota + + + + + {` resets ${formatResetDuration(quota()?.fiveHour?.resetAfterSeconds ?? null)}`} + + + + - {quota !== null && !showQuota && ( - {`Quota: ${quota.status}`} - )} + + {`Quota: ${quota()?.status}`} + - - Tokens (this session) - + + Tokens (this session) + + - + ); } diff --git a/src/tui/token-table.tsx b/src/tui/token-table.tsx index 0858add..5b1c40d 100644 --- a/src/tui/token-table.tsx +++ b/src/tui/token-table.tsx @@ -4,9 +4,15 @@ * Each model row shows: modelID, input, output, cache (read+write). * Total row sums across all models. * When there are no models, renders "No usage yet". + * + * IMPORTANT (Solid reactivity): the component function body runs ONCE. + * The `if (models.length === 0) return ...` early-return pattern does NOT + * react to prop changes — it captures the initial value forever. We use + * instead so the fallback re-evaluates when props.models changes. + * Totals are computed via createMemo so they update reactively. */ -import { For } from "solid-js"; +import { For, Show, createMemo } from "solid-js"; import type { ReportModel } from "../report/build"; import { compactNumber } from "../report/compact"; import type { ThemeColors } from "./theme"; @@ -17,42 +23,45 @@ export interface TokenTableProps { } export function TokenTable(props: TokenTableProps) { - if (props.models.length === 0) { - return No usage yet; - } - - let totalInput = 0; - let totalOutput = 0; - let totalCache = 0; - - for (const m of props.models) { - totalInput += m.input; - totalOutput += m.output; - totalCache += m.cacheRead + m.cacheWrite; - } + const totals = createMemo(() => { + let totalInput = 0; + let totalOutput = 0; + let totalCache = 0; + for (const m of props.models) { + totalInput += m.input; + totalOutput += m.output; + totalCache += m.cacheRead + m.cacheWrite; + } + return { totalInput, totalOutput, totalCache }; + }); return ( - - {(m) => } - - {"Total "} - {`${compactNumber(totalInput)} in`} - {` ${compactNumber(totalOutput)} out`} - {totalCache > 0 ? ` ${compactNumber(totalCache)} cache` : ""} - - + 0} + fallback={ No usage yet} + > + + {(m) => } + + {"Total "} + {`${compactNumber(totals().totalInput)} in`} + {` ${compactNumber(totals().totalOutput)} out`} + {totals().totalCache > 0 ? ` ${compactNumber(totals().totalCache)} cache` : ""} + + + ); } function ModelRow(props: { model: ReportModel; colors: ThemeColors }) { - const m = props.model; - const cache = m.cacheRead + m.cacheWrite; return ( - {` ${m.modelID}`} + {` ${props.model.modelID}`} - {` ${compactNumber(m.input)} in ${compactNumber(m.output)} out`} - {cache > 0 ? ` ${compactNumber(cache)} cache` : ""} + {` ${compactNumber(props.model.input)} in ${compactNumber(props.model.output)} out`} + {props.model.cacheRead + props.model.cacheWrite > 0 + ? ` ${compactNumber(props.model.cacheRead + props.model.cacheWrite)} cache` + : ""} ); diff --git a/test/unit/wham-provider.test.ts b/test/unit/wham-provider.test.ts index 01ef1ac..7608c50 100644 --- a/test/unit/wham-provider.test.ts +++ b/test/unit/wham-provider.test.ts @@ -224,6 +224,84 @@ describe("parseWhamResponse", () => { expect(parseWhamResponse(null).ok).toBe(false); expect(parseWhamResponse(42).ok).toBe(false); }); + + it("parses the real wham response shape (rate_limit.primary_window / secondary_window)", () => { + // This is the actual shape returned by https://chatgpt.com/backend-api/wham/usage + // captured from a live plus-plan account on 2026-07-18. + const realResponse = { + user_id: "user-xxx", + account_id: "user-xxx", + email: "user@example.com", + plan_type: "plus", + rate_limit: { + allowed: true, + limit_reached: false, + primary_window: { + used_percent: 46, + limit_window_seconds: 604800, + reset_after_seconds: 595692, + reset_at: 1784987952, + }, + secondary_window: null, + }, + code_review_rate_limit: null, + additional_rate_limits: null, + credits: { + has_credits: false, + unlimited: false, + overage_limit_reached: false, + balance: "0", + }, + spend_control: { reached: false, individual_limit: null }, + }; + + const result = parseWhamResponse(realResponse); + expect(result.ok).toBe(true); + expect(result.planType).toBe("plus"); + expect(result.windows).toHaveLength(1); + expect(result.windows[0].kind).toBe("weekly"); + expect(result.windows[0].usedPercent).toBe(46); + expect(result.windows[0].windowSeconds).toBe(604800); + expect(result.windows[0].resetAfterSeconds).toBe(595692); + // reset_at is a Unix timestamp (number) → converted to ISO string. + expect(result.windows[0].resetsAt).toBe("2026-07-25T13:59:12.000Z"); + expect(result.credits?.hasCredits).toBe(false); + expect(result.credits?.balance).toBe("0"); + }); + + it("parses real wham response with both primary and secondary windows", () => { + const withBoth = { + plan_type: "pro", + rate_limit: { + primary_window: { + used_percent: 62.3, + limit_window_seconds: 604800, + reset_after_seconds: 345600, + reset_at: 1784987952, + }, + secondary_window: { + used_percent: 37.5, + limit_window_seconds: 18000, + reset_after_seconds: 8040, + reset_at: 1784987952, + }, + }, + credits: { has_credits: true, unlimited: false, balance: "14.50" }, + }; + + const result = parseWhamResponse(withBoth); + expect(result.ok).toBe(true); + expect(result.planType).toBe("pro"); + expect(result.windows).toHaveLength(2); + + const weekly = result.windows.find((w) => w.kind === "weekly"); + const fiveHour = result.windows.find((w) => w.kind === "five-hour"); + expect(weekly).toBeDefined(); + expect(weekly?.usedPercent).toBe(62.3); + expect(fiveHour).toBeDefined(); + expect(fiveHour?.usedPercent).toBe(37.5); + expect(result.credits?.balance).toBe("14.50"); + }); }); // ── identifyWindow tests ─────────────────────────────────────────────