Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7d42c3c
feat: display provider usage limits in settings
Aditya190803 Aug 16, 2026
9fa025d
fix: address usage-limit review findings
Aditya190803 Aug 16, 2026
c268aa4
fix: show provider quotas on usage page and read Cursor /usage
Aditya190803 Aug 16, 2026
94fab2b
fix(server): harden provider usage probes
Aditya190803 Aug 16, 2026
63ef2ee
fix(server): preserve provider usage snapshots
Aditya190803 Aug 16, 2026
c5438f7
fix(usage): improve provider quota presentation
Aditya190803 Aug 16, 2026
d647ec8
Merge origin/main into feat/provider-usage-limits
Aditya190803 Aug 22, 2026
c944734
fix(usage): keep live-patched quota windows through provider refreshes
Aditya190803 Aug 22, 2026
9200c38
Merge branch 'main' of https://github.com/pingdotgg/t3code into feat/…
Aditya190803 Aug 23, 2026
a77580a
Merge branch 'main' into feat/provider-usage-limits
Aditya190803 Aug 23, 2026
8b30c42
Merge branch 'main' of https://github.com/pingdotgg/t3code into feat/…
Aditya190803 Aug 27, 2026
a2e02c7
fix(usage): address remaining provider usage-limit review comments
Aditya190803 Aug 27, 2026
16bff0c
fix(server): defer PTY exit replay and kill Windows process trees
Aditya190803 Aug 27, 2026
fdc78fb
Update apps/server/src/terminal/NodePtyAdapter.ts
Aditya190803 Aug 27, 2026
bf1f482
feat(web): show current-provider usage in the chat box
Aditya190803 Aug 27, 2026
3168a0c
fix(web): hide chat usage until the first message
Aditya190803 Aug 27, 2026
e464240
Merge branch 'main' into feat/usage-in-chat
Aditya190803 Aug 27, 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ artifacts/app-store/screenshots/
native/**/target/
node_modules/
.alchemy/
# ClaudeTextGeneration.test.ts writes a real .claude.json here and leaves it
# behind, so an unscoped `git add` would commit live session data.
.claude-work-test/
*.log
.env*
!.env.example
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const clientSettings: ClientSettings = {
glassOpacity: 80,
planModeEnabled: false,
showSkillsInSlashMenu: false,
showProviderUsageInComposer: true,
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
sidebarAutoSettleOnMerge: true,
Expand Down
183 changes: 183 additions & 0 deletions apps/mobile/src/features/usage/UsageRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@ import {
import { useMemo, useState } from "react";
import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useAtomValue } from "@effect/atom-react";
import type { EnvironmentId, ServerConfig, ServerProvider } from "@t3tools/contracts";

import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
import { AppText as Text } from "../../components/AppText";
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
import { useServerConfigs } from "../../state/entities";
import { environmentPresentations } from "../../state/presentation";
import { SettingsSection } from "../settings/components/SettingsSection";
import { UsageDailyChart } from "./UsageDailyChart";
import type { UsageChartMetric } from "./usageChartData";
Expand Down Expand Up @@ -115,6 +119,8 @@ export function UsageRouteScreen() {
onSelect={selectWindow}
/>

<ProviderQuotaSection />

<UsageCoverageNotice environments={environments} merged={merged} isPartial={isPartial} />

{isPending ? (
Expand Down Expand Up @@ -498,3 +504,180 @@ function UsageCoverageNotice(props: {
</View>
);
}

function providerQuotaLabel(provider: ServerProvider): string {
if (provider.displayName) return provider.displayName;
if (provider.driver === "codex") return "Codex";
if (provider.driver === "claudeAgent" || provider.driver === "claude") return "Claude";
if (provider.driver === "cursor") return "Cursor";
if (provider.driver === "grok") return "Grok";
if (provider.driver === "opencode") return "OpenCode";
return provider.instanceId;
}

function shouldShowProviderQuota(provider: ServerProvider): boolean {
if (provider.driver === "opencode") return false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium usage/UsageRouteScreen.tsx:519

shouldShowProviderQuota hides every provider with driver === "opencode", so mobile users connected through OpenCode Go/Zen never see quota data even when the server snapshot has available opencodeManaged limits. Remove this unconditional exclusion so those providers pass the existing visibility checks.

-  if (provider.driver === "opencode") return false;
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/usage/UsageRouteScreen.tsx around line 519:

`shouldShowProviderQuota` hides every provider with `driver === "opencode"`, so mobile users connected through OpenCode Go/Zen never see quota data even when the server snapshot has available `opencodeManaged` limits. Remove this unconditional exclusion so those providers pass the existing visibility checks.

return provider.enabled && provider.installed && provider.availability !== "unavailable";
}

function isGrokFreeTier(provider: ServerProvider): boolean {
if (provider.driver !== "grok") return false;
const tier = (provider.auth.label ?? provider.auth.type ?? "").trim().toLowerCase();
return tier === "free";
}

function providerQuotaNotice(provider: ServerProvider): string | null {
if (isGrokFreeTier(provider)) {
return "Usage is only shown for paid tiers";
}
if (!provider.usageLimits) return "Usage data unavailable";
if (provider.usageLimits.available) return null;
return provider.usageLimits.reason ?? "Usage data unavailable";
}

function formatQuotaResetDate(resetsAt: string | undefined): string | null {
if (!resetsAt) return null;
const date = new Date(resetsAt);
if (Number.isNaN(date.getTime())) return null;
if (/T00:00:00(?:\.000)?Z$/.test(resetsAt)) {
return new Intl.DateTimeFormat(undefined, {
month: "numeric",
day: "numeric",
year: "numeric",
timeZone: "UTC",
}).format(date);
}
return new Intl.DateTimeFormat(undefined, {
month: "numeric",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date);
}

function sharedUsageResetAt(
windows: NonNullable<ServerProvider["usageLimits"]>["windows"],
): string | undefined {
if (windows.length === 0) return undefined;
const first = windows[0]?.resetsAt;
if (!first) return undefined;
return windows.every((window) => window.resetsAt === first) ? first : undefined;
}

function quotaBarColor(percent: number): string {
if (percent >= 90) return "bg-destructive";
return "bg-foreground";
}

function ProviderQuotaSection() {
const configs = useServerConfigs();
const presentations = useAtomValue(environmentPresentations.presentationsAtom);
const groups = collectMobileQuotaGroups(configs, presentations);
if (groups.length === 0) return null;

return (
<SettingsSection title="Provider limits" card>
{groups.flatMap((group, groupIndex) =>
group.providers.map((provider, providerIndex) => {
const first = groupIndex === 0 && providerIndex === 0;
const notice = providerQuotaNotice(provider);
const usageLimits = provider.usageLimits;
const sharedReset = usageLimits?.available
? sharedUsageResetAt(usageLimits.windows)
: undefined;
const sharedResetStr = formatQuotaResetDate(sharedReset);
return (
<View
key={`${group.environmentId}:${provider.instanceId}`}
className={first ? "gap-3 p-4" : "gap-3 border-t border-border-subtle p-4"}
>
<Text className="text-lg text-foreground">
{group.environmentLabel
? `${group.environmentLabel} · ${providerQuotaLabel(provider)}`
: providerQuotaLabel(provider)}
</Text>
{notice ? (
<Text className="text-sm text-foreground-muted">{notice}</Text>
) : usageLimits?.available ? (
<>
{usageLimits.windows.map((window) => {
const roundedPercent = Math.round(
Math.max(0, Math.min(100, window.usedPercent)),
);
const remainingPercent = 100 - roundedPercent;
const resetDateStr = sharedReset ? null : formatQuotaResetDate(window.resetsAt);
return (
<View
key={`${window.kind}:${window.label}:${window.resetsAt ?? "none"}`}
className="gap-1.5"
>
<View className="flex-row items-baseline justify-between gap-3">
<Text className="text-sm text-foreground">{window.label}</Text>
<Text className="text-sm text-foreground-muted">
{remainingPercent}% remaining
</Text>
</View>
<View className="h-1.5 overflow-hidden rounded-full bg-subtle">
<View
className={`h-full rounded-full ${quotaBarColor(roundedPercent)}`}
style={{ width: `${roundedPercent}%` }}
/>
</View>
{resetDateStr ? (
<Text className="text-xs text-foreground-muted">
Resets {resetDateStr}
</Text>
) : null}
</View>
);
})}
{sharedResetStr ? (
<Text className="text-xs text-foreground-muted">Resets {sharedResetStr}</Text>
) : null}
</>
) : usageLimits ? (
<Text className="text-sm text-foreground-muted">
{usageLimits.reason ?? "Usage data unavailable"}
</Text>
) : null}
</View>
);
}),
)}
</SettingsSection>
);
}

function collectMobileQuotaGroups(
configs: ReadonlyMap<EnvironmentId, ServerConfig>,
presentations: ReadonlyMap<
EnvironmentId,
{ readonly entry: { readonly target: { readonly label: string } } }
>,
): ReadonlyArray<{
readonly environmentId: EnvironmentId;
readonly environmentLabel: string | null;
readonly providers: readonly ServerProvider[];
}> {
const showEnvironmentLabels = configs.size > 1;
const groups: Array<{
readonly environmentId: EnvironmentId;
readonly environmentLabel: string | null;
readonly providers: readonly ServerProvider[];
}> = [];

for (const [environmentId, config] of configs) {
const providers = config.providers.filter(shouldShowProviderQuota);
if (providers.length === 0) continue;
groups.push({
environmentId,
environmentLabel: showEnvironmentLabels
? (presentations.get(environmentId)?.entry.target.label ?? environmentId)
: null,
providers,
});
}

return groups;
}
9 changes: 9 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ import * as Duration from "effect/Duration";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import { HttpClient } from "effect/unstable/http";
import { ChildProcessSpawner } from "effect/unstable/process";

import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts";
import * as PtyAdapter from "../../terminal/PtyAdapter.ts";
import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
import { ServerConfig } from "../../config.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
Expand Down Expand Up @@ -56,6 +58,7 @@ import {
type ProviderSnapshotSettings,
} from "../providerUpdateSettings.ts";
import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts";

const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);

const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
Expand Down Expand Up @@ -166,6 +169,8 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
});
const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd);

const ptyAdapter = Option.getOrUndefined(yield* Effect.serviceOption(PtyAdapter.PtyAdapter));

// Kick the TTL-gated manifest refresh in the background and classify
// with the in-memory manifest, so a slow or hung fetch never delays the
// provider check. A refresh that lands mid-probe applies on the next one.
Expand All @@ -187,6 +192,10 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
(effect) =>
ptyAdapter
? effect.pipe(Effect.provideService(PtyAdapter.PtyAdapter, ptyAdapter))
: effect,
);

const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
Expand Down
10 changes: 9 additions & 1 deletion apps/server/src/provider/Drivers/CursorDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tool
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import { HttpClient } from "effect/unstable/http";
Expand All @@ -40,6 +41,7 @@ import {
} from "../ProviderDriver.ts";
import type { ServerProviderDraft } from "../providerSnapshot.ts";
import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
import * as PtyAdapter from "../../terminal/PtyAdapter.ts";
import {
makeProviderMaintenanceCapabilities,
type ProviderMaintenanceCapabilitiesResolver,
Expand Down Expand Up @@ -105,6 +107,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const { cwd } = yield* ServerConfig;
const httpClient = yield* HttpClient.HttpClient;
const serverSettings = yield* ServerSettingsService;
const eventLoggers = yield* ProviderEventLoggers;
Expand All @@ -131,13 +134,18 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
instanceId,
});
const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv);
const ptyAdapter = Option.getOrUndefined(yield* Effect.serviceOption(PtyAdapter.PtyAdapter));

const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe(
const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv, cwd).pipe(
Effect.map(stampIdentity),
Effect.provideService(Crypto.Crypto, crypto),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
(effect) =>
ptyAdapter
? effect.pipe(Effect.provideService(PtyAdapter.PtyAdapter, ptyAdapter))
: effect,
);

const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/provider/Drivers/GrokDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { GrokSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import { HttpClient } from "effect/unstable/http";
Expand All @@ -11,6 +12,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
import { ServerConfig } from "../../config.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts";
import * as PtyAdapter from "../../terminal/PtyAdapter.ts";
import { ProviderDriverError } from "../Errors.ts";
import { makeGrokAdapter } from "../Layers/GrokAdapter.ts";
import {
Expand Down Expand Up @@ -114,10 +116,15 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
});
const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv);

const ptyAdapter = Option.getOrUndefined(yield* Effect.serviceOption(PtyAdapter.PtyAdapter));
const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe(
Effect.map(stampIdentity),
Effect.provideService(Crypto.Crypto, crypto),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
(effect) =>
ptyAdapter
? effect.pipe(Effect.provideService(PtyAdapter.PtyAdapter, ptyAdapter))
: effect,
);

const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
Expand Down
Loading
Loading