Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
133 changes: 133 additions & 0 deletions apps/mobile/src/features/usage/UsageLimitsPooled.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {
EnvironmentId,
ProviderDriverKind,
ProviderInstanceId,
type ServerProvider,
type ServerProviderUsageWindow,
} from "@t3tools/contracts";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { describe, expect, it, vi } from "vite-plus/test";

const state = vi.hoisted(() => ({ presentations: new Map(), navigate: vi.fn() }));
vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations }));
vi.mock("@react-navigation/native", () => ({
useNavigation: () => ({ navigate: state.navigate }),
}));
vi.mock("react-native", () => ({
Platform: { OS: "ios" },
Pressable: "Pressable",
ScrollView: "ScrollView",
View: "View",
}));
vi.mock("react-native-svg", () => ({
Svg: "Svg",
Defs: "Defs",
Path: "Path",
Pattern: "Pattern",
Rect: "Rect",
}));
vi.mock("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
}));
vi.mock("../../components/AndroidScreenHeader", () => ({ AndroidScreenHeader: () => null }));
vi.mock("../../components/AppSymbol", () => ({ SymbolView: () => null }));
vi.mock("../../components/AppText", () => ({ AppText: "Text" }));
vi.mock("../../components/ProviderIcon", () => ({ ProviderIcon: () => null }));
vi.mock("../../native/StackHeader", () => ({ NativeStackScreenOptions: {} }));
vi.mock("../../state/presentation", () => ({
environmentPresentations: { presentationsAtom: null },
}));
vi.mock("./UsageLimitsSection", () => ({ ResetCredits: () => null }));
vi.mock("./usageProviders", () => ({
useProviderColors: () => ({ codex: "#000", claude: "#fff" }),
}));

import { UsageLimitsSection } from "./UsageLimitsPooled";

Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const now = Date.parse("2026-09-03T12:00:00Z");
const home = EnvironmentId.make("home");
const windows: readonly ServerProviderUsageWindow[] = [
{ id: "totalPercentUsed", kind: "monthly", label: "Monthly", usedPercent: 15 },
{ id: "apiPercentUsed", kind: "monthly", label: "Monthly · API", usedPercent: 49 },
{ id: "autoPercentUsed", kind: "monthly", label: "Monthly · Auto", usedPercent: 9 },
];
function presentation(readings: readonly ServerProviderUsageWindow[]) {
const provider = {
instanceId: ProviderInstanceId.make("cursor"),
driver: ProviderDriverKind.make("cursor"),
enabled: true,
installed: true,
version: null,
status: "ready",
auth: { status: "authenticated", email: "cursor@example.com" },
checkedAt: "2026-09-03T11:00:00Z",
models: [],
slashCommands: [],
skills: [],
usageLimits: { checkedAt: "2026-09-03T11:00:00Z", windows: readings },
} satisfies ServerProvider;
return { entry: { target: { label: "Home" } }, serverConfig: { providers: [provider] } };
}

describe("mobile Cursor Limits", () => {
it("keeps pool labels, older readings and account navigation correct when the selection changes", async () => {
state.presentations = new Map([[home, presentation(windows)]]);
state.navigate.mockReset();
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => {
renderer = create(
<UsageLimitsSection
now={now}
failedLabels={[]}
selectedEnvironmentIds={new Set([home])}
/>,
);
});
let tree = JSON.stringify(renderer?.toJSON());
expect(tree).toContain("Cursor Models");
expect(tree).toContain("Other Models");
expect(tree).toContain("Auto can use either pool.");
expect(tree).not.toContain("Overall");
const open = renderer?.root.findAll(
(node) => node.props.accessibilityHint === "Show account details",
)[0];
await act(async () => open?.props.onPress());
expect(state.navigate).toHaveBeenCalledWith(
"SettingsSheet",
expect.objectContaining({
params: expect.objectContaining({
params: expect.objectContaining({
windowId: "autoPercentUsed",
environmentIds: [home],
}),
}),
}),
);
state.presentations = new Map([[home, presentation([windows[0]!])]]);
await act(async () =>
renderer?.update(
<UsageLimitsSection
now={now}
failedLabels={[]}
selectedEnvironmentIds={new Set([home])}
/>,
),
);
tree = JSON.stringify(renderer?.toJSON());
expect(tree).toContain("Overall");
expect(tree).not.toContain("Cursor Models");
await act(async () =>
renderer?.update(
<UsageLimitsSection now={now} failedLabels={[]} selectedEnvironmentIds={new Set()} />,
),
);
tree = JSON.stringify(renderer?.toJSON());
expect(tree).not.toContain("Overall");
expect(tree).toContain("Select an environment to see limits.");
} finally {
await act(async () => renderer?.unmount());
}
});
});
35 changes: 25 additions & 10 deletions apps/mobile/src/features/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
collectLimitAccounts,
collectLimitNotices,
collectLimitPools,
cursorUsageWindowDetails,
displayLimitWindows,
formatDuration,
formatResetsIn,
remainingPercent,
Expand Down Expand Up @@ -79,11 +81,15 @@ function PoolWindowCard({
color,
now,
environmentIds,
label,
description,
}: {
readonly pool: LimitPoolWindow;
readonly color: string;
readonly now: number;
readonly environmentIds: readonly string[] | null;
readonly label?: string | undefined;
readonly description?: string | undefined;
}) {
const navigation = useNavigation();
const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0);
Expand All @@ -105,7 +111,7 @@ function PoolWindowCard({
<View className="gap-3 rounded-[24px] border-continuous bg-card p-4">
<View className="flex-row items-start justify-between gap-3">
<View className="gap-1">
<Text className="text-sm font-t3-medium text-foreground">{pool.label}</Text>
<Text className="text-sm font-t3-medium text-foreground">{label ?? pool.label}</Text>
<View className="flex-row items-baseline gap-1.5">
<Text className="text-3xl font-t3-bold tabular-nums text-foreground">
{pool.remainingPercent}%
Expand All @@ -117,6 +123,7 @@ function PoolWindowCard({
<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 ? (
<Text className="text-xs tabular-nums text-foreground-muted">
↻ +{nextRefill.restoresPercent}%{" "}
Expand Down Expand Up @@ -235,15 +242,23 @@ export function UsageLimitsSection({
{DRIVER_LABEL[pool.driver] ?? pool.driver}
</Text>
</View>
{pool.windows.map((window) => (
<PoolWindowCard
key={`${window.kind}:${window.id}`}
pool={window}
color={pool.driver === "claudeAgent" ? colors.claude : colors.codex}
now={now}
environmentIds={selectedEnvironmentIds === null ? null : [...selectedEnvironmentIds]}
/>
))}
{displayLimitWindows(pool).map((window) => {
const details =
pool.driver === "cursor" ? cursorUsageWindowDetails(window.id) : undefined;
return (
<PoolWindowCard
key={`${window.kind}:${window.id}`}
pool={window}
color={pool.driver === "claudeAgent" ? colors.claude : colors.codex}
now={now}
environmentIds={
selectedEnvironmentIds === null ? null : [...selectedEnvironmentIds]
}
label={details?.label}
description={details?.description}
/>
);
})}
</View>
))}
{notices.length > 0 || failedLabels.length > 0 ? (
Expand Down
10 changes: 5 additions & 5 deletions apps/server/src/provider/Layers/CursorProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -893,21 +893,21 @@ describe("Cursor usage limits", () => {
{
id: "totalPercentUsed",
kind: "monthly",
label: "Monthly",
label: "Overall",
usedPercent: 72.4,
resetsAt: "2026-09-20T03:53:06.000Z",
},
{
id: "autoPercentUsed",
kind: "monthly",
label: "Monthly · Auto",
label: "Cursor Models",
usedPercent: 69.5,
resetsAt: "2026-09-20T03:53:06.000Z",
},
{
id: "apiPercentUsed",
kind: "monthly",
label: "Monthly · API",
label: "Other Models",
usedPercent: 100,
resetsAt: "2026-09-20T03:53:06.000Z",
},
Expand All @@ -921,10 +921,10 @@ describe("Cursor usage limits", () => {
);
expect(
cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 0 } }, checkedAt).windows,
).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Monthly", usedPercent: 0 }]);
).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 0 }]);
expect(
cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 150 } }, checkedAt).windows,
).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Monthly", usedPercent: 100 }]);
).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 100 }]);
});

it("reads the instance's credentials and endpoint even when usage enabled is false", async () => {
Expand Down
11 changes: 4 additions & 7 deletions apps/server/src/provider/Layers/cursorUsageLimits.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as NodeOS from "node:os";
import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
Expand Down Expand Up @@ -39,15 +40,11 @@ export function cursorUsageResponseToLimits(
: undefined;
const windows: ServerProviderUsageWindow[] = [];
if (response.planUsage) {
for (const [key, label] of [
["totalPercentUsed", "Monthly"],
["autoPercentUsed", "Monthly · Auto"],
["apiPercentUsed", "Monthly · API"],
] as const) {
const usedPercent = response.planUsage[key];
for (const { id, label } of CURSOR_USAGE_WINDOWS) {
const usedPercent = response.planUsage[id];
if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue;
windows.push({
id: key,
id,
kind: "monthly",
label,
usedPercent: clampPercent(usedPercent),
Expand Down
84 changes: 84 additions & 0 deletions apps/web/src/components/usage/UsageLimitsPooled.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {
EnvironmentId,
ProviderDriverKind,
ProviderInstanceId,
type ServerProvider,
type ServerProviderUsageWindow,
} from "@t3tools/contracts";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vite-plus/test";

vi.mock("../../hooks/useSettings", () => ({ usePrimarySettings: () => ({}) }));
vi.mock("../chat/ProviderInstanceIcon", () => ({ ProviderInstanceIcon: () => null }));
vi.mock("../settings/providerDriverMeta", () => ({ getDriverOption: () => ({ label: "Cursor" }) }));
vi.mock("./UsageLimits", () => ({
PaceIcon: () => null,
ResetCreditDialog: () => null,
barColor: () => "#000",
resetCreditsSummary: () => null,
useResetCredit: () => ({}),
}));
vi.mock("../ui/popover", () => ({
Popover: ({ children }: { children: ReactNode }) => children,
PopoverTrigger: ({ children }: { children: ReactNode }) => <button>{children}</button>,
PopoverPopup: () => null,
}));

import { UsageLimitsPooled } from "./UsageLimitsPooled";

const now = Date.parse("2026-09-03T12:00:00Z");
const windows: readonly ServerProviderUsageWindow[] = [
{ id: "totalPercentUsed", kind: "monthly", label: "Monthly", usedPercent: 15 },
{ id: "apiPercentUsed", kind: "monthly", label: "Monthly · API", usedPercent: 49 },
{ id: "autoPercentUsed", kind: "monthly", label: "Monthly · Auto", usedPercent: 9 },
];
function provider(readings = windows, id = "cursor") {
return {
instanceId: ProviderInstanceId.make(id),
driver: ProviderDriverKind.make("cursor"),
enabled: true,
installed: true,
version: null,
status: "ready",
auth: { status: "authenticated", email: `${id}@example.com` },
checkedAt: "2026-09-03T11:00:00Z",
models: [],
slashCommands: [],
skills: [],
usageLimits: { checkedAt: "2026-09-03T11:00:00Z", windows: readings },
} satisfies ServerProvider;
}
function render(providers: readonly ServerProvider[]) {
const presentations = new Map([
[
EnvironmentId.make("desktop"),
{ entry: { target: { label: "Desktop" } }, serverConfig: { providers } },
],
]);
return renderToStaticMarkup(<UsageLimitsPooled presentations={presentations} now={now} />);
}

describe("Cursor Limits cards", () => {
it("uses existing readings to explain both usable pools without showing a third quota", () => {
const markup = render([provider()]);
expect(markup).toContain("Cursor Models");
expect(markup).toContain("Other Models");
expect(markup).toContain("Auto can use either pool.");
expect(markup).not.toContain(">Overall<");
expect(markup).not.toContain(">Monthly<");
expect(markup.indexOf("Cursor Models")).toBeLessThan(markup.indexOf("Other Models"));
});
it("keeps combined-only accounts and unfamiliar readings visible", () => {
const markup = render([
provider(),
provider(
[windows[0]!, { id: "future", kind: "other", label: "Future allowance", usedPercent: 20 }],
"older",
),
]);
expect(markup).toContain(">Overall<");
expect(markup).toContain("Combined usage across both allowances, not a third quota.");
expect(markup).toContain("Future allowance");
});
});
Loading
Loading