diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index ca0608738fe9..460827b22649 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -18,6 +18,7 @@ import { limitsNotice, paceOf, providerLimitsLabel, + remainingPercent, } from "@t3tools/shared/usageLimits"; import { type ReactNode, useState } from "react"; import { Alert, Pressable, View } from "react-native"; @@ -44,9 +45,10 @@ function useBarColor(driver: Driver): string | null { } /** - * One window as a bar spanning its whole duration: the fill is quota spent, - * the hairline is how far into the window the clock is. Pace sits under the - * left edge, the countdown under the right, so a row reads in one glance. + * One window as a bar spanning its whole duration: the fill is quota left, + * the hairline is how much of the window is left, so even spending keeps the + * fill on the line. Pace sits under the left edge, the countdown under the + * right, so a row reads in one glance. */ function WindowRow(props: { readonly window: ServerProviderUsageWindow; @@ -54,37 +56,40 @@ function WindowRow(props: { readonly now: number; }) { const { window, now } = props; - const used = Math.round(Math.max(0, Math.min(100, window.usedPercent))); + const remaining = remainingPercent(window); const elapsed = elapsedShare(window, now); + const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100); const pace = paceOf(window, now); const resetsIn = formatResetsIn(window, now); return ( {window.label} - {used}% + + {remaining}% left + = 90 + remaining <= 10 ? "h-full rounded-full bg-red-500" - : used >= 70 + : remaining <= 30 ? "h-full rounded-full bg-amber-500" : "h-full rounded-full bg-foreground" } style={[ - { flex: used }, - used < 70 && props.color ? { backgroundColor: props.color } : null, + { flex: remaining }, + remaining > 30 && props.color ? { backgroundColor: props.color } : null, ]} /> - + - {elapsed !== null ? ( + {timeLeft !== null ? ( ) : null} diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 5584cfc169f5..1a72af35c909 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -20,6 +20,7 @@ import { type LimitPace, paceOf, providerLimitsLabel, + remainingPercent, } from "@t3tools/shared/usageLimits"; import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; import { Fragment, useState } from "react"; @@ -95,14 +96,16 @@ function WindowBar({ readonly now: number; }) { const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); - const used = Math.max(0, Math.min(100, window.usedPercent)); + const remaining = remainingPercent(window); const elapsed = elapsedShare(window, now); + // The fill is quota left, so the even-spending mark is the time left. + const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100); const resetsIn = formatResetsIn(window, now); const resetsAt = window.resetsAt ? formatUpcomingTimestamp(window.resetsAt, timestampFormat, now) : null; - const summary = `${window.label}: ${Math.round(used)}% used${ - elapsed === null ? "" : `, ${Math.round(elapsed * 100)}% of the window elapsed` + const summary = `${window.label}: ${remaining}% left${ + timeLeft === null ? "" : `, ${timeLeft}% of the window left` }${resetsIn ? `, ${resetsIn}` : ""}`; return ( @@ -118,27 +121,26 @@ function WindowBar({ } >
- {used > 0 ? ( + {remaining > 0 ? (
) : null} - {elapsed !== null ? ( + {timeLeft !== null ? ( ) : null}
- {Math.round(used)}% used - {elapsed !== null ? ` · ${Math.round(elapsed * 100)}% of the window elapsed` : ""} + {remaining}% left{timeLeft !== null ? ` · ${timeLeft}% of the window left` : ""} - {elapsed !== null ? ( + {timeLeft !== null ? ( The line is where even spending would be. ) : null} {resetsAt ? ( @@ -185,7 +187,7 @@ export function LimitWindows({ {window.label} - {Math.round(window.usedPercent)}% + {remainingPercent(window)}% left diff --git a/docs/user/usage.md b/docs/user/usage.md index dc92b3f65917..4e4196a46337 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -39,9 +39,9 @@ the dialog. ## Track subscription limits -**Usage → Limits** shows quota use and reset times for Codex and Claude subscriptions. It also -compares quota consumed with time elapsed in each window, so you can judge your pace before the -next reset. +**Usage → Limits** shows how much quota is left in each window and when it resets, for Codex and +Claude subscriptions. For windows with timing data, each bar also marks how much of the window is +left, so you can judge your pace before the next reset. If a window looks stale, refresh Limits to re-check every provider and hub. diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 6b1952199dd2..fede8813e913 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -20,6 +20,7 @@ import { limitsNotice, paceOf, providersWithLimits, + remainingPercent, } from "./usageLimits.ts"; const now = Date.parse("2026-09-03T12:00:00.000Z"); @@ -493,6 +494,15 @@ describe("sameUsageLimitCommandCoverage", () => { }); }); +describe("remainingPercent", () => { + it("inverts and clamps the reported usage", () => { + expect(remainingPercent(window)).toBe(60); + expect(remainingPercent({ ...window, usedPercent: 0 })).toBe(100); + expect(remainingPercent({ ...window, usedPercent: 100 })).toBe(0); + expect(remainingPercent({ ...window, usedPercent: 33.4 })).toBe(67); + }); +}); + describe("isUsageLimitsCommand", () => { it("recognizes only the standalone local action", () => { expect(isUsageLimitsCommand(" /USAGE-LIMITS\n")).toBe(true); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index a792b20d6fd3..5cb5303320bd 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -166,6 +166,11 @@ export function limitsNotice(limits: ServerProviderUsageLimits): string | null { return limits.windows.length === 0 ? "No limits reported." : null; } +/** Quota left in the window, 0..100. Bars and labels show what remains, as Codex does. */ +export function remainingPercent(window: ServerProviderUsageWindow): number { + return Math.round(100 - Math.max(0, Math.min(100, window.usedPercent))); +} + function resetMillis(window: ServerProviderUsageWindow): number | null { if (window.resetsAt === undefined) return null; const at = Date.parse(window.resetsAt); @@ -184,9 +189,9 @@ export function elapsedShare(window: ServerProviderUsageWindow, now: number): nu export type LimitPace = "ahead" | "on" | "under"; /** - * Usage against the clock. The bar is the whole window, so the elapsed share - * is also where even spending would have put the fill; within five points of - * it counts as on pace. + * Usage against the clock. Spending evenly leaves the same share of quota as + * there is time left in the window; within five points of that counts as on + * pace, further ahead means the window may run dry first. */ export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null { const elapsed = elapsedShare(window, now);