| author | |
| committer | |
| log | be53bbd85044e937c359a89b6004c3d3c38ffcf1 |
| tree | 70c9df93df174b795e7065246edd8262c4c875b2 |
| parent | 585ce2c2ab396e4c3c5ffcfc422449c07896ddf9 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>5 files changed, 51 insertions(+), 29 deletions(-)
apps/mobile/src/features/usage/UsageLimitsSection.tsx+17-12| ... | @@ -18,6 +18,7 @@ import { | ... | @@ -18,6 +18,7 @@ import { |
| 18 | limitsNotice, | 18 | limitsNotice, |
| 19 | paceOf, | 19 | paceOf, |
| 20 | providerLimitsLabel, | 20 | providerLimitsLabel, |
| 21 | remainingPercent, | ||
| 21 | } from "@t3tools/shared/usageLimits"; | 22 | } from "@t3tools/shared/usageLimits"; |
| 22 | import { type ReactNode, useState } from "react"; | 23 | import { type ReactNode, useState } from "react"; |
| 23 | import { Alert, Pressable, View } from "react-native"; | 24 | import { Alert, Pressable, View } from "react-native"; |
| ... | @@ -44,9 +45,10 @@ function useBarColor(driver: Driver): string | null { | ... | @@ -44,9 +45,10 @@ function useBarColor(driver: Driver): string | null { |
| 44 | } | 45 | } |
| 45 | 46 | ||
| 46 | /** | 47 | /** |
| 47 | * One window as a bar spanning its whole duration: the fill is quota spent, | 48 | * One window as a bar spanning its whole duration: the fill is quota left, |
| 48 | * the hairline is how far into the window the clock is. Pace sits under the | 49 | * the hairline is how much of the window is left, so even spending keeps the |
| 49 | * left edge, the countdown under the right, so a row reads in one glance. | 50 | * fill on the line. Pace sits under the left edge, the countdown under the |
| 51 | * right, so a row reads in one glance. | ||
| 50 | */ | 52 | */ |
| 51 | function WindowRow(props: { | 53 | function WindowRow(props: { |
| 52 | readonly window: ServerProviderUsageWindow; | 54 | readonly window: ServerProviderUsageWindow; |
| ... | @@ -54,37 +56,40 @@ function WindowRow(props: { | ... | @@ -54,37 +56,40 @@ function WindowRow(props: { |
| 54 | readonly now: number; | 56 | readonly now: number; |
| 55 | }) { | 57 | }) { |
| 56 | const { window, now } = props; | 58 | const { window, now } = props; |
| 57 | const used = Math.round(Math.max(0, Math.min(100, window.usedPercent))); | 59 | const remaining = remainingPercent(window); |
| 58 | const elapsed = elapsedShare(window, now); | 60 | const elapsed = elapsedShare(window, now); |
| 61 | const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100); | ||
| 59 | const pace = paceOf(window, now); | 62 | const pace = paceOf(window, now); |
| 60 | const resetsIn = formatResetsIn(window, now); | 63 | const resetsIn = formatResetsIn(window, now); |
| 61 | return ( | 64 | return ( |
| 62 | <View className="gap-1"> | 65 | <View className="gap-1"> |
| 63 | <View className="flex-row items-baseline justify-between gap-3"> | 66 | <View className="flex-row items-baseline justify-between gap-3"> |
| 64 | <Text className="text-sm text-foreground">{window.label}</Text> | 67 | <Text className="text-sm text-foreground">{window.label}</Text> |
| 65 | <Text className="text-sm font-t3-medium tabular-nums text-foreground">{used}%</Text> | 68 | <Text className="text-sm font-t3-medium tabular-nums text-foreground"> |
| 69 | {remaining}% left | ||
| 70 | </Text> | ||
| 66 | </View> | 71 | </View> |
| 67 | <View className="h-3 justify-center"> | 72 | <View className="h-3 justify-center"> |
| 68 | <View className="h-1.5 flex-row overflow-hidden rounded-full bg-subtle"> | 73 | <View className="h-1.5 flex-row overflow-hidden rounded-full bg-subtle"> |
| 69 | <View | 74 | <View |
| 70 | className={ | 75 | className={ |
| 71 | used >= 90 | 76 | remaining <= 10 |
| 72 | ? "h-full rounded-full bg-red-500" | 77 | ? "h-full rounded-full bg-red-500" |
| 73 | : used >= 70 | 78 | : remaining <= 30 |
| 74 | ? "h-full rounded-full bg-amber-500" | 79 | ? "h-full rounded-full bg-amber-500" |
| 75 | : "h-full rounded-full bg-foreground" | 80 | : "h-full rounded-full bg-foreground" |
| 76 | } | 81 | } |
| 77 | style={[ | 82 | style={[ |
| 78 | { flex: used }, | 83 | { flex: remaining }, |
| 79 | used < 70 && props.color ? { backgroundColor: props.color } : null, | 84 | remaining > 30 && props.color ? { backgroundColor: props.color } : null, |
| 80 | ]} | 85 | ]} |
| 81 | /> | 86 | /> |
| 82 | <View style={{ flex: 100 - used }} /> | 87 | <View style={{ flex: 100 - remaining }} /> |
| 83 | </View> | 88 | </View> |
| 84 | {elapsed !== null ? ( | 89 | {timeLeft !== null ? ( |
| 85 | <View | 90 | <View |
| 86 | className="absolute top-0 bottom-0 w-px bg-foreground" | 91 | className="absolute top-0 bottom-0 w-px bg-foreground" |
| 87 | style={{ left: `${elapsed * 100}%`, opacity: 0.6 }} | 92 | style={{ left: `${timeLeft}%`, opacity: 0.6 }} |
| 88 | /> | 93 | /> |
| 89 | ) : null} | 94 | ) : null} |
| 90 | </View> | 95 | </View> |
apps/web/src/components/usage/UsageLimits.tsx+13-11| ... | @@ -20,6 +20,7 @@ import { | ... | @@ -20,6 +20,7 @@ import { |
| 20 | type LimitPace, | 20 | type LimitPace, |
| 21 | paceOf, | 21 | paceOf, |
| 22 | providerLimitsLabel, | 22 | providerLimitsLabel, |
| 23 | remainingPercent, | ||
| 23 | } from "@t3tools/shared/usageLimits"; | 24 | } from "@t3tools/shared/usageLimits"; |
| 24 | import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; | 25 | import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; |
| 25 | import { Fragment, useState } from "react"; | 26 | import { Fragment, useState } from "react"; |
| ... | @@ -95,14 +96,16 @@ function WindowBar({ | ... | @@ -95,14 +96,16 @@ function WindowBar({ |
| 95 | readonly now: number; | 96 | readonly now: number; |
| 96 | }) { | 97 | }) { |
| 97 | const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); | 98 | const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); |
| 98 | const used = Math.max(0, Math.min(100, window.usedPercent)); | 99 | const remaining = remainingPercent(window); |
| 99 | const elapsed = elapsedShare(window, now); | 100 | const elapsed = elapsedShare(window, now); |
| 101 | // The fill is quota left, so the even-spending mark is the time left. | ||
| 102 | const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100); | ||
| 100 | const resetsIn = formatResetsIn(window, now); | 103 | const resetsIn = formatResetsIn(window, now); |
| 101 | const resetsAt = window.resetsAt | 104 | const resetsAt = window.resetsAt |
| 102 | ? formatUpcomingTimestamp(window.resetsAt, timestampFormat, now) | 105 | ? formatUpcomingTimestamp(window.resetsAt, timestampFormat, now) |
| 103 | : null; | 106 | : null; |
| 104 | const summary = `${window.label}: ${Math.round(used)}% used${ | 107 | const summary = `${window.label}: ${remaining}% left${ |
| 105 | elapsed === null ? "" : `, ${Math.round(elapsed * 100)}% of the window elapsed` | 108 | timeLeft === null ? "" : `, ${timeLeft}% of the window left` |
| 106 | }${resetsIn ? `, ${resetsIn}` : ""}`; | 109 | }${resetsIn ? `, ${resetsIn}` : ""}`; |
| 107 | 110 | ||
| 108 | return ( | 111 | return ( |
| ... | @@ -118,27 +121,26 @@ function WindowBar({ | ... | @@ -118,27 +121,26 @@ function WindowBar({ |
| 118 | } | 121 | } |
| 119 | > | 122 | > |
| 120 | <div className="absolute inset-x-0 inset-y-1.5 rounded-full bg-muted" /> | 123 | <div className="absolute inset-x-0 inset-y-1.5 rounded-full bg-muted" /> |
| 121 | {used > 0 ? ( | 124 | {remaining > 0 ? ( |
| 122 | <div | 125 | <div |
| 123 | className="absolute inset-y-1.5 left-0 rounded-full" | 126 | className="absolute inset-y-1.5 left-0 rounded-full" |
| 124 | style={{ width: `${used}%`, backgroundColor: color }} | 127 | style={{ width: `${remaining}%`, backgroundColor: color }} |
| 125 | /> | 128 | /> |
| 126 | ) : null} | 129 | ) : null} |
| 127 | {elapsed !== null ? ( | 130 | {timeLeft !== null ? ( |
| 128 | <span | 131 | <span |
| 129 | aria-hidden | 132 | aria-hidden |
| 130 | className="absolute inset-y-0.5 w-px -translate-x-1/2 bg-foreground/60" | 133 | className="absolute inset-y-0.5 w-px -translate-x-1/2 bg-foreground/60" |
| 131 | style={{ left: `${elapsed * 100}%` }} | 134 | style={{ left: `${timeLeft}%` }} |
| 132 | /> | 135 | /> |
| 133 | ) : null} | 136 | ) : null} |
| 134 | </TooltipTrigger> | 137 | </TooltipTrigger> |
| 135 | <TooltipPopup side="top" className="max-w-72 text-xs"> | 138 | <TooltipPopup side="top" className="max-w-72 text-xs"> |
| 136 | <div className="flex flex-col gap-0.5"> | 139 | <div className="flex flex-col gap-0.5"> |
| 137 | <span className="text-foreground"> | 140 | <span className="text-foreground"> |
| 138 | {Math.round(used)}% used | 141 | {remaining}% left{timeLeft !== null ? ` · ${timeLeft}% of the window left` : ""} |
| 139 | {elapsed !== null ? ` · ${Math.round(elapsed * 100)}% of the window elapsed` : ""} | ||
| 140 | </span> | 142 | </span> |
| 141 | {elapsed !== null ? ( | 143 | {timeLeft !== null ? ( |
| 142 | <span className="text-muted-foreground">The line is where even spending would be.</span> | 144 | <span className="text-muted-foreground">The line is where even spending would be.</span> |
| 143 | ) : null} | 145 | ) : null} |
| 144 | {resetsAt ? ( | 146 | {resetsAt ? ( |
| ... | @@ -185,7 +187,7 @@ export function LimitWindows({ | ... | @@ -185,7 +187,7 @@ export function LimitWindows({ |
| 185 | <span className="flex min-w-0 items-center gap-2 text-xs"> | 187 | <span className="flex min-w-0 items-center gap-2 text-xs"> |
| 186 | <span className="truncate text-muted-foreground">{window.label}</span> | 188 | <span className="truncate text-muted-foreground">{window.label}</span> |
| 187 | <span className="ms-auto shrink-0 font-medium text-foreground tabular-nums"> | 189 | <span className="ms-auto shrink-0 font-medium text-foreground tabular-nums"> |
| 188 | {Math.round(window.usedPercent)}% | 190 | {remainingPercent(window)}% left |
| 189 | </span> | 191 | </span> |
| 190 | </span> | 192 | </span> |
| 191 | <WindowBar color={color} window={window} now={now} /> | 193 | <WindowBar color={color} window={window} now={now} /> |
docs/user/usage.md+3-3| ... | @@ -39,9 +39,9 @@ the dialog. | ... | @@ -39,9 +39,9 @@ the dialog. |
| 39 | 39 | ||
| 40 | ## Track subscription limits | 40 | ## Track subscription limits |
| 41 | 41 | ||
| 42 | **Usage → Limits** shows quota use and reset times for Codex and Claude subscriptions. It also | 42 | **Usage → Limits** shows how much quota is left in each window and when it resets, for Codex and |
| 43 | compares quota consumed with time elapsed in each window, so you can judge your pace before the | 43 | Claude subscriptions. For windows with timing data, each bar also marks how much of the window is |
| 44 | next reset. | 44 | left, so you can judge your pace before the next reset. |
| 45 | 45 | ||
| 46 | If a window looks stale, refresh Limits to re-check every provider and hub. | 46 | If a window looks stale, refresh Limits to re-check every provider and hub. |
| 47 | 47 |
packages/shared/src/usageLimits.test.ts+10| ... | @@ -20,6 +20,7 @@ import { | ... | @@ -20,6 +20,7 @@ import { |
| 20 | limitsNotice, | 20 | limitsNotice, |
| 21 | paceOf, | 21 | paceOf, |
| 22 | providersWithLimits, | 22 | providersWithLimits, |
| 23 | remainingPercent, | ||
| 23 | } from "./usageLimits.ts"; | 24 | } from "./usageLimits.ts"; |
| 24 | 25 | ||
| 25 | const now = Date.parse("2026-09-03T12:00:00.000Z"); | 26 | const now = Date.parse("2026-09-03T12:00:00.000Z"); |
| ... | @@ -493,6 +494,15 @@ describe("sameUsageLimitCommandCoverage", () => { | ... | @@ -493,6 +494,15 @@ describe("sameUsageLimitCommandCoverage", () => { |
| 493 | }); | 494 | }); |
| 494 | }); | 495 | }); |
| 495 | 496 | ||
| 497 | describe("remainingPercent", () => { | ||
| 498 | it("inverts and clamps the reported usage", () => { | ||
| 499 | expect(remainingPercent(window)).toBe(60); | ||
| 500 | expect(remainingPercent({ ...window, usedPercent: 0 })).toBe(100); | ||
| 501 | expect(remainingPercent({ ...window, usedPercent: 100 })).toBe(0); | ||
| 502 | expect(remainingPercent({ ...window, usedPercent: 33.4 })).toBe(67); | ||
| 503 | }); | ||
| 504 | }); | ||
| 505 | |||
| 496 | describe("isUsageLimitsCommand", () => { | 506 | describe("isUsageLimitsCommand", () => { |
| 497 | it("recognizes only the standalone local action", () => { | 507 | it("recognizes only the standalone local action", () => { |
| 498 | expect(isUsageLimitsCommand(" /USAGE-LIMITS\n")).toBe(true); | 508 | expect(isUsageLimitsCommand(" /USAGE-LIMITS\n")).toBe(true); |
packages/shared/src/usageLimits.ts+8-3| ... | @@ -166,6 +166,11 @@ export function limitsNotice(limits: ServerProviderUsageLimits): string | null { | ... | @@ -166,6 +166,11 @@ export function limitsNotice(limits: ServerProviderUsageLimits): string | null { |
| 166 | return limits.windows.length === 0 ? "No limits reported." : null; | 166 | return limits.windows.length === 0 ? "No limits reported." : null; |
| 167 | } | 167 | } |
| 168 | 168 | ||
| 169 | /** Quota left in the window, 0..100. Bars and labels show what remains, as Codex does. */ | ||
| 170 | export function remainingPercent(window: ServerProviderUsageWindow): number { | ||
| 171 | return Math.round(100 - Math.max(0, Math.min(100, window.usedPercent))); | ||
| 172 | } | ||
| 173 | |||
| 169 | function resetMillis(window: ServerProviderUsageWindow): number | null { | 174 | function resetMillis(window: ServerProviderUsageWindow): number | null { |
| 170 | if (window.resetsAt === undefined) return null; | 175 | if (window.resetsAt === undefined) return null; |
| 171 | const at = Date.parse(window.resetsAt); | 176 | const at = Date.parse(window.resetsAt); |
| ... | @@ -184,9 +189,9 @@ export function elapsedShare(window: ServerProviderUsageWindow, now: number): nu | ... | @@ -184,9 +189,9 @@ export function elapsedShare(window: ServerProviderUsageWindow, now: number): nu |
| 184 | export type LimitPace = "ahead" | "on" | "under"; | 189 | export type LimitPace = "ahead" | "on" | "under"; |
| 185 | 190 | ||
| 186 | /** | 191 | /** |
| 187 | * Usage against the clock. The bar is the whole window, so the elapsed share | 192 | * Usage against the clock. Spending evenly leaves the same share of quota as |
| 188 | * is also where even spending would have put the fill; within five points of | 193 | * there is time left in the window; within five points of that counts as on |
| 189 | * it counts as on pace. | 194 | * pace, further ahead means the window may run dry first. |
| 190 | */ | 195 | */ |
| 191 | export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null { | 196 | export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null { |
| 192 | const elapsed = elapsedShare(window, now); | 197 | const elapsed = elapsedShare(window, now); |