diff --git a/src/run.ts b/src/run.ts index 09350e8..3a6f7b2 100644 --- a/src/run.ts +++ b/src/run.ts @@ -741,6 +741,7 @@ type AccountStatus = { } | null; usagePacks: { total: number; used: number; remaining: number }; totalRemaining: number; + free?: { remainingPercent: number | null; resetsAt: string }; }; activity30d: { requests: number; @@ -806,6 +807,7 @@ function parseAccountStatus(value: unknown): AccountStatus | null { const activity = asRecord(root.activity30d); const planValue = billing.plan; const plan = planValue === null ? null : asRecord(planValue); + const free = billing.free === undefined ? null : asRecord(billing.free); if ( typeof key.id !== "string" || typeof key.alias !== "string" || typeof billing.status !== "string" || typeof billing.currency !== "string" || @@ -818,6 +820,10 @@ function parseAccountStatus(value: unknown): AccountStatus | null { (plan !== null && ( typeof plan.id !== "string" || typeof plan.name !== "string" || !hasNumbers(plan, ["allowance", "used", "remaining"]) + )) || + (free !== null && ( + (free.remainingPercent !== null && !hasNumbers(free, ["remainingPercent"])) || + typeof free.resetsAt !== "string" || Number.isNaN(Date.parse(free.resetsAt)) )) ) return null; @@ -838,19 +844,34 @@ function printAccountStatus( const packs = account.billing.usagePacks; const activity = account.activity30d; + const free = account.billing.free; + const bar = (ratio: number) => { + const width = 24; + const filled = Math.round(ratio * width); + return colors.good("█".repeat(filled)) + colors.muted("░".repeat(width - filled)); + }; + io.stdout.write(`\n${colors.muted("ACCOUNT")}\n`); - io.stdout.write( - ` ${plan ? colors.good("◆") : colors.muted("◇")} Plan ` + - `${plan ? colors.strong(plan.name) : colors.muted("none")} ` + - `${colors.muted(account.billing.status)}\n`, - ); + if (free) { + io.stdout.write(` ${colors.good("◆")} Plan ${colors.strong("Free")}\n`); + const resets = colors.muted(`resets in ${formatTimeUntil(free.resetsAt)}`); + io.stdout.write( + free.remainingPercent === null + ? ` ${resets}\n` + : ` ${bar(free.remainingPercent / 100)} ${colors.strong(`${free.remainingPercent}%`)} ` + + `${colors.muted("left today ·")} ${resets}\n`, + ); + } else { + io.stdout.write( + ` ${plan ? colors.good("◆") : colors.muted("◇")} Plan ` + + `${plan ? colors.strong(plan.name) : colors.muted("none")} ` + + `${colors.muted(account.billing.status)}\n`, + ); + } if (plan) { const ratio = plan.allowance > 0 ? Math.min(Math.max(plan.used / plan.allowance, 0), 1) : 0; - const width = 24; - const filled = Math.round(ratio * width); - const bar = colors.good("█".repeat(filled)) + colors.muted("░".repeat(width - filled)); io.stdout.write( - ` ${bar} ${colors.strong(`${Math.round(ratio * 100)}%`)} ${colors.muted("used")}\n`, + ` ${bar(ratio)} ${colors.strong(`${Math.round(ratio * 100)}%`)} ${colors.muted("used")}\n`, ); } io.stdout.write( @@ -863,6 +884,12 @@ function printAccountStatus( ); } +function formatTimeUntil(iso: string): string { + const minutes = Math.max(0, Math.ceil((Date.parse(iso) - Date.now()) / 60_000)); + const hours = Math.floor(minutes / 60); + return hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`; +} + function money(value: number, currency: string): string { return new Intl.NumberFormat("en-US", { style: "currency", diff --git a/tests/run.test.ts b/tests/run.test.ts index 085feec..8a9d507 100644 --- a/tests/run.test.ts +++ b/tests/run.test.ts @@ -679,6 +679,31 @@ describe("zro experience", () => { expect(output).toContain("Activity 15 requests · 1.3K tokens (30d)"); }); + it("shows a Free account its share of today's usage and when it resets, with no plan dollars", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-free-status-")); + const stdout = new PassThrough(); + const free = accountStatusResponse(); + free.billing = { + ...free.billing, + status: "setup_required", + plan: null, + usagePacks: { total: 0, used: 0, remaining: 0 }, + totalRemaining: 0, + free: { remainingPercent: 64, resetsAt: new Date(Date.now() + (5 * 60 + 12) * 60_000 - 30_000).toISOString() }, + } as typeof free.billing; + const code = await run(["status"], { + ...io(home, stdout), + env: { ZRO_API_KEY: "sk-status-secret" }, + fetch: async () => Response.json(free), + }); + + expect(code).toBe(0); + const output = await streamText(stdout); + expect(output).toContain("Plan Free"); + expect(output).toContain("64% left today · resets in 5h 12m"); + expect(output).not.toContain("none"); + }); + it("reports a rejected stored key as disconnected", async () => { const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-rejected-status-")); const credentialDir = path.join(home, ".config", "zro");