Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
7eb49ab
feat(usage): show reserve and deficit pace on Limits
cursoragent Sep 10, 2026
548dce3
fix(usage): keep Limits pace verdict readable
cursoragent Sep 10, 2026
8b8ba6a
fix(usage): mark pace with a colored pill, not a 1
cursoragent Sep 10, 2026
fb13de3
fix(usage): tuck pace percent beside leftover quota
cursoragent Sep 10, 2026
faf72c4
fix(usage): match CodexBar even-spend pace rounding
cursoragent Sep 10, 2026
420c3da
fix(usage): keep the pace tick tall and make it thinner
cursoragent Sep 10, 2026
5e4c6e6
fix(usage): drop repeating account labels on single-account Limits
cursoragent Sep 10, 2026
2df926f
fix(usage): address Limits pace review comments
cursoragent Sep 10, 2026
4f6eac3
refactor(usage): keep Limits pace to even-spend math and labels
cursoragent Sep 10, 2026
fbf8764
fix(usage): pin single-account pool pace and match Limits docs
cursoragent Sep 10, 2026
214799d
docs(usage): keep numbered Limits rows to narrow multi-account bars
cursoragent Sep 10, 2026
27d375c
fix(usage): keep the existing Limits account row
cursoragent Sep 10, 2026
d41d87d
fix(usage): overshoot the Limits pace tick and match its color
cursoragent Sep 10, 2026
7aa257e
fix(usage): overshoot the Limits pace tick by 10%
cursoragent Sep 10, 2026
a947549
fix(usage): keep Limits pace labels muted
cursoragent Sep 10, 2026
59a00fe
fix(usage): keep the mobile Limits segment number above the tick
cursoragent Sep 11, 2026
bfb4a87
feat(usage): average Limits pace across pooled accounts
cursoragent Sep 11, 2026
dec6095
docs(usage): call the Limits pace chip a pace figure
cursoragent Sep 11, 2026
556653d
merge: sync Limits pace branch with upstream main
cursoragent Sep 14, 2026
a9b22b5
merge: sync Limits pace branch with upstream main
cursoragent Sep 28, 2026
cf8b578
Merge branch 'main' into cursor/usage-limits-pacing-f418
baanish Sep 28, 2026
c35da71
fix(usage): keep the even-pace hairline and show mobile direction
cursoragent Sep 28, 2026
440b932
fix(usage): align pooled pace with the quota it describes
cursoragent Sep 28, 2026
3ac110d
fix(usage): mark even pace with a grey tick
cursoragent Sep 30, 2026
65c7be4
fix(usage): call the pace mark expected pace
cursoragent Sep 30, 2026
50fbc74
docs(usage): plain wording for expected pace
cursoragent Sep 30, 2026
cf63c8f
fix(usage): use the composer pace line on Limits
cursoragent Sep 30, 2026
98aec80
fix(usage): render the composer pace tooltip on Limits
cursoragent Sep 30, 2026
58584be
fix(usage): draw the shared pace mark as a grey tick
cursoragent Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 43 additions & 18 deletions apps/mobile/src/features/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
collectLimitNotices,
collectLimitPools,
cursorUsageWindowDetails,
describeWindowPace,
displayLimitWindows,
formatDuration,
formatResetsIn,
Expand All @@ -23,11 +24,10 @@ import { AppText as Text } from "../../components/AppText";
import { ProviderIcon } from "../../components/ProviderIcon";
import { SettingsScreen } from "../settings/components/SettingsScreen";
import { environmentPresentations } from "../../state/presentation";
import { ResetCredits } from "./UsageLimitsSection";
import { PaceLine, ResetCredits } from "./UsageLimitsSection";
import { useProviderColors } from "./usageProviders";

const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" };
const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const;

function accountName(account: LimitAccount) {
if (account.displayName) return account.displayName;
Expand Down Expand Up @@ -110,9 +110,6 @@ function PoolWindowCard({
<Text className="text-sm text-foreground-muted">left</Text>
</View>
</View>
{pool.pace ? (
<Text className="text-xs text-foreground-tertiary">{PACE_LABEL[pool.pace]}</Text>
) : null}
</View>
{description ? <Text className="text-xs text-foreground-muted">{description}</Text> : null}
{nextRefill ? (
Expand All @@ -124,21 +121,28 @@ function PoolWindowCard({
<View className="flex-row gap-1">
{pool.columns.map(({ account, window }, index) => {
if (!window) return <View key={account.key} className="h-7 min-w-0 flex-1" />;
const pace = describeWindowPace(window, now);
return (
<Pressable
key={account.key}
accessibilityRole="button"
accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left`}
accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${pace.summary}`}
accessibilityHint="Show account details"
onPress={() => openAccount(account)}
className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle"
className="relative h-7 min-w-0 flex-1 overflow-visible rounded-md bg-transparent"
>
<AccountSegment
remaining={remainingPercent(window)}
color={color}
pending={Boolean(window.resetsAt)}
/>
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<View className="absolute inset-0 overflow-hidden rounded-md bg-subtle">
<AccountSegment
remaining={pace.remaining}
color={color}
pending={Boolean(window.resetsAt)}
/>
</View>
{pace.timeLeft !== null ? <PaceLine percent={pace.timeLeft} /> : null}
<View
pointerEvents="none"
className="absolute inset-0 z-20 items-center justify-center"
>
<Text className="text-xs font-t3-medium tabular-nums text-foreground">
{index + 1}
</Text>
Expand Down Expand Up @@ -215,7 +219,7 @@ export function UsageLimitsSection({
selectedEnvironmentIds === null
? presentations
: new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id)));
const pools = collectLimitPools(collectLimitAccounts(selected), now);
const pools = collectLimitPools(collectLimitAccounts(selected));
const notices = collectLimitNotices(selected);
const colors = useProviderColors();
const cursorPromptAt =
Expand Down Expand Up @@ -304,6 +308,29 @@ type AccountScreenProps = StaticScreenProps<{
now: number;
}>;

function AccountPace({
window,
now,
}: {
readonly window: Parameters<typeof describeWindowPace>[0];
readonly now: number;
}) {
const pace = describeWindowPace(window, now);
return (
<>
<Text className="text-3xl font-t3-bold tabular-nums text-foreground">
{pace.remaining}% left
</Text>
{pace.detail ? (
<View className="gap-1">
<Text className="text-sm text-foreground-muted">{pace.summary}</Text>
<Text className="text-sm text-foreground-muted">{pace.detail}</Text>
</View>
) : null}
</>
);
}

/** Resolve the account again so live quota and credit updates reach the open detail screen. */
export function UsageLimitAccountScreen({ route }: AccountScreenProps) {
const insets = useSafeAreaInsets();
Expand All @@ -317,7 +344,7 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) {
: new Map([...presentations].filter(([id]) => selectedIds.has(id)));
const accounts = collectLimitAccounts(selected);
const account = accounts.find((candidate) => candidate.key === accountKey);
const pool = collectLimitPools(accounts, now)
const pool = collectLimitPools(accounts)
.find((candidate) => candidate.driver === account?.driver)
?.windows.find((candidate) => candidate.id === windowId && candidate.kind === windowKind);
const window = pool?.members.find((member) => member.account.key === accountKey)?.window;
Expand Down Expand Up @@ -363,9 +390,7 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) {
</View>
<View className="gap-3 rounded-[24px] border-continuous bg-card p-4">
<Text className="text-sm font-t3-medium text-foreground">{window.label}</Text>
<Text className="text-3xl font-t3-bold tabular-nums text-foreground">
{remainingPercent(window)}% left
</Text>
<AccountPace window={window} now={now} />
{window.resetsAt ? (
<Text selectable className="text-sm text-foreground-muted">
Resets{" "}
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/usage/UsageLimitsSection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations }
vi.mock("react-native", () => ({ Alert: {}, Pressable: "button", View: "div" }));
vi.mock("../../components/AppText", () => ({ AppText: "span" }));
vi.mock("../../components/ProviderIcon", () => ({ ProviderIcon: () => null }));
vi.mock("../../components/AppSymbol", () => ({ SymbolView: () => null }));
vi.mock("./usageProviders", () => ({ useProviderColors: () => ({}) }));
vi.mock("../../state/presentation", () => ({
environmentPresentations: { presentationsAtom: null },
Expand Down
62 changes: 29 additions & 33 deletions apps/mobile/src/features/usage/UsageLimitsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,10 @@ import type {
UsageProviderKind,
} from "@t3tools/contracts";
import {
elapsedShare,
describeWindowPace,
formatDuration,
formatResetsIn,
limitsNotice,
paceOf,
remainingPercent,
} from "@t3tools/shared/usageLimits";
import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react";
import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage";
Expand All @@ -27,8 +25,6 @@ import { serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
import { useProviderColors } from "./usageProviders";

const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const;

type Driver = ServerProvider["driver"];

/** The series colour the usage chart uses for this driver, so the two views read as one. */
Expand All @@ -39,60 +35,60 @@ function useBarColor(driver: Driver): string | null {
return kind ? colors[kind] : null;
}

/** Grey tick, 10% taller than the bar on each side. Same mark as the web bar. */
export function PaceLine({ percent }: { readonly percent: number }) {
return (
<View
pointerEvents="none"
className="absolute z-10 w-0.5 rounded-full border border-background bg-foreground/70"
style={{ left: `${percent}%`, marginLeft: -1, top: "-10%", height: "120%" }}
/>
);
}

/**
* 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.
* One window as a bar spanning its whole duration. The fill is quota left.
* The line is how much of the window is left. The countdown sits under the bar.
*/
function WindowRow(props: {
readonly window: ServerProviderUsageWindow;
readonly color: string | null;
readonly now: number;
}) {
const { window, now } = props;
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 pace = describeWindowPace(window, now);
const resetsIn = formatResetsIn(window, now);
return (
<View className="gap-1">
<View className="flex-row items-baseline justify-between gap-3">
<Text className="text-sm text-foreground">{window.label}</Text>
<Text className="text-sm font-t3-medium tabular-nums text-foreground">
{remaining}% left
</Text>
<View className="flex-row items-center gap-1.5">
<Text className="text-sm font-t3-medium tabular-nums text-foreground">
{pace.remaining}% left
</Text>
</View>
</View>
<View className="h-3 justify-center">
<View className="relative h-3.5 justify-center">
<View className="h-1.5 flex-row overflow-hidden rounded-full bg-subtle">
<View
className={
remaining <= 10
pace.remaining <= 10
? "h-full rounded-full bg-red-500"
: remaining <= 30
: pace.remaining <= 30
? "h-full rounded-full bg-amber-500"
: "h-full rounded-full bg-foreground"
}
style={[
{ flex: remaining },
remaining > 30 && props.color ? { backgroundColor: props.color } : null,
{ flex: pace.remaining },
pace.remaining > 30 && props.color ? { backgroundColor: props.color } : null,
]}
/>
<View style={{ flex: 100 - remaining }} />
<View style={{ flex: 100 - pace.remaining }} />
</View>
{timeLeft !== null ? (
<View
className="absolute top-0 bottom-0 w-px bg-foreground"
style={{ left: `${timeLeft}%`, opacity: 0.6 }}
/>
) : null}
{pace.timeLeft !== null ? <PaceLine percent={pace.timeLeft} /> : null}
</View>
{pace || resetsIn ? (
<View className="flex-row justify-between gap-3">
<Text className="text-xs text-foreground-tertiary">{pace ? PACE_LABEL[pace] : ""}</Text>
<Text className="text-xs tabular-nums text-foreground-tertiary">{resetsIn ?? ""}</Text>
</View>
{resetsIn ? (
<Text className="text-xs tabular-nums text-foreground-tertiary">{resetsIn}</Text>
) : null}
</View>
);
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/widgets/subscriptionUsageSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function subscriptionUsageProps(
configuredDrivers: ReadonlySet<string>,
maxWindowsPerProvider: number,
): SubscriptionUsageSnapshot {
const pools = collectLimitPools(accounts, now);
const pools = collectLimitPools(accounts);
const checked = accounts
.filter((account) => account.driver === "codex" || account.driver === "claudeAgent")
.map((account) => Date.parse(account.limits.checkedAt));
Expand Down
1 change: 0 additions & 1 deletion apps/server/src/provider/Layers/codexUsageLimits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ describe("codexRateLimitsToLimits", () => {
kind: "monthly",
label: "Monthly",
usedPercent: 80,
windowDurationMins: 43_200,
},
]);
});
Expand Down
12 changes: 8 additions & 4 deletions apps/server/src/provider/Layers/codexUsageLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,16 +81,20 @@ function codexRateLimitsToWindows(
const windows: ServerProviderUsageWindow[] = [];
for (const [id, window, fallbackMins] of positions) {
if (!window || !Number.isFinite(window.usedPercent)) continue;
const windowDurationMins =
typeof window.windowDurationMins === "number" ? window.windowDurationMins : fallbackMins;
const kind = kindForDuration(windowDurationMins);
// The fallback only names the window. Storing it as a duration would pace
// a Free/Go month as if every cycle were 30 days.
const reportedMins =
typeof window.windowDurationMins === "number" && window.windowDurationMins > 0
? window.windowDurationMins
: undefined;
const kind = kindForDuration(reportedMins ?? fallbackMins);
const resetsAt = isoFromEpochSeconds(window.resetsAt);
windows.push({
id,
kind,
label: labelForKind(kind),
usedPercent: clampPercent(window.usedPercent),
windowDurationMins,
...(reportedMins !== undefined ? { windowDurationMins: reportedMins } : {}),
...(resetsAt ? { resetsAt } : {}),
});
}
Expand Down
Loading
Loading