Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
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;
Comment thread
Aditya190803 marked this conversation as resolved.
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";

Comment thread
Aditya190803 marked this conversation as resolved.
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