authorgravatar for chris@xenforo.comChris Deeming <chris@xenforo.com> 2026-09-06 00:17:36+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 16:17:36-07:00
log183c3433091c30a19117ee733832b1c44a6af074
tree707106828dfc6bf934ba90e028bb8193ff11005f
parent748fe0f8b68b7951df6797d4c22d2ca01314a3dc
signature Signed by PGP key B5690EEEBB952194

feat: show provider usage limits with /usage-limits (#9875)


23 files changed, 1315 insertions(+), 128 deletions(-)

apps/mobile/src/connection/runtime.ts+3-1
......@@ -31,7 +31,9 @@ type ConnectionLayerSource =
3131 | typeof mobileBackgroundActivityReporterLayer;
3232
3333const providedClientConnectionLayer = snapshotLoaderLayer.pipe(
34 Layer.provideMerge(Connection.layerWithOptions({ usageLimitSources: true })),
34 Layer.provideMerge(
35 Connection.layerWithOptions({ usageLimitSources: true, usageLimitsCommand: true }),
36 ),
3537 Layer.provideMerge(
3638 Layer.mergeAll(
3739 runtimeContextLayer,
apps/mobile/src/features/threads/ComposerUsageLimits.tsx created+103
......@@ -0,0 +1,103 @@
1import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts";
2import { Pressable, ScrollView, useWindowDimensions, View } from "react-native";
3
4import { SymbolView } from "../../components/AppSymbol";
5import { AppText as Text } from "../../components/AppText";
6import { AccountLimits, ResetCredits } from "../usage/UsageLimitsSection";
7
8const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" };
9
10/**
11 * The /usage-limits result, docked above the composer. It is the Usage → Limits
12 * card one size down, so the two read as the same thing. The surface is opaque
13 * because nothing blurs the feed behind it.
14 */
15export function ComposerUsageLimits({
16 report,
17 environmentId,
18 onClose,
19}: {
20 readonly report: UsageLimitsReport;
21 readonly environmentId: EnvironmentId;
22 readonly onClose: () => void;
23}) {
24 const now = Date.parse(report.createdAt);
25 const { height } = useWindowDimensions();
26 const close = (
27 <Pressable
28 accessibilityLabel="Dismiss usage limits"
29 accessibilityRole="button"
30 hitSlop={12}
31 onPress={onClose}
32 className="-me-1 p-1 active:opacity-60"
33 >
34 <SymbolView name="xmark" size={14} tintColorClassName="accent-icon-muted" type="monochrome" />
35 </Pressable>
36 );
37 return (
38 <View className="overflow-hidden rounded-[20px] border-continuous bg-card">
39 <ScrollView
40 bounces={false}
41 showsVerticalScrollIndicator={false}
42 style={{ maxHeight: Math.round(height * 0.4) }}
43 >
44 {report.accounts.map((account, index) => {
45 const driverLabel = DRIVER_LABEL[account.driver] ?? String(account.driver);
46 return (
47 <AccountLimits
48 key={account.id}
49 dense
50 first={index === 0}
51 driver={account.driver}
52 label={driverLabel}
53 // Siblings need telling apart: a custom instance without a name shows its
54 // id, and a pooled account shows its hub and account id.
55 instanceLabel={
56 account.instanceId
57 ? account.displayName?.trim() ||
58 (String(account.instanceId) !== String(account.driver)
59 ? account.instanceId
60 : driverLabel)
61 : account.label
62 }
63 detail={account.plan}
64 limits={account.limits}
65 now={now}
66 trailing={index === 0 ? close : undefined}
67 footer={
68 account.instanceId && account.limits.resetCredits ? (
69 <ResetCredits
70 dense
71 environmentId={environmentId}
72 instanceId={account.instanceId}
73 credits={account.limits.resetCredits}
74 now={now}
75 />
76 ) : undefined
77 }
78 />
79 );
80 })}
81 {report.accounts.length === 0 ? (
82 // Nothing but notices, so the close control needs a row of its own.
83 <View className="flex-row items-center gap-3 px-4 pt-3">
84 <Text className="min-w-0 flex-1 text-base text-foreground">Usage limits</Text>
85 {close}
86 </View>
87 ) : null}
88 {report.notices.map((notice) => (
89 <Text
90 key={notice}
91 className={
92 report.accounts.length === 0
93 ? "px-4 py-3 text-xs text-foreground-muted"
94 : "border-t border-border-subtle px-4 py-3 text-xs text-foreground-muted"
95 }
96 >
97 {notice}
98 </Text>
99 ))}
100 </ScrollView>
101 </View>
102 );
103}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+24
......@@ -49,6 +49,7 @@ import { VideoPreviewModal, type VideoPreviewSource } from "../../components/Vid
4949import { ProviderIcon } from "../../components/ProviderIcon";
5050import { SymbolView } from "../../components/AppSymbol";
5151import { AppText as Text } from "../../components/AppText";
52import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits";
5253import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer";
5354import { ShimmeringWorkContent } from "./thread-work-log";
5455import { ComposerCommandPopover } from "./ComposerCommandPopover";
......@@ -309,6 +310,14 @@ export function NewTaskDraftScreen(props: {
309310 const isComposerInteractionLocked = isIncomingShareTransferPending || flow.submitting;
310311 // Also guard while a submit is in flight: an Android back press or iOS
311312 // Cancel would otherwise abandon the screen while the task still starts.
313 // T3 owns /usage-limits only where Limits has data for the selected provider.
314 const offersUsageLimits =
315 flow.selectedProviderStatus !== null &&
316 hasProviderUsageLimits(
317 flow.selectedProviderStatus.driver,
318 selectedEnvironmentServerConfig?.providers ?? [],
319 selectedEnvironmentServerConfig?.usageLimitSources ?? [],
320 );
312321 const composerMenu = useComposerCommandMenu({
313322 draftMessage: flow.prompt,
314323 ownerKey: flow.draftKey,
......@@ -320,6 +329,7 @@ export function NewTaskDraftScreen(props: {
320329 selectedProviderStatus: flow.selectedProviderStatus,
321330 hasThread: false,
322331 hasCompactableConversation: false,
332 offersUsageLimits: offersUsageLimits,
323333 enabled: isComposerFocused && !isComposerInteractionLocked,
324334 onChangeDraftMessage: flow.setPrompt,
325335 onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined,
......@@ -908,6 +918,20 @@ export function NewTaskDraftScreen(props: {
908918 );
909919 return;
910920 }
921 // T3's own limits command is answered by the thread composer; a new task would
922 // send it to the agent. A provider's same-named command, or a prompt carrying
923 // attachments, goes through as usual.
924 if (
925 offersUsageLimits &&
926 isUsageLimitsCommand(initialMessageText) &&
927 draft.attachments.length === 0
928 ) {
929 Alert.alert(
930 "Usage limits",
931 "Send /usage-limits inside a thread, or open Settings → Usage → Limits.",
932 );
933 return;
934 }
911935 // A failed-send restore can leave the draft over the cap on purpose (it
912936 // never drops the user's files); starting anyway would upload everything
913937 // and have the server reject the turn.
apps/mobile/src/features/threads/ThreadComposer.tsx+52-3
......@@ -7,7 +7,13 @@ import type {
77 ProviderInteractionMode,
88 RuntimeMode,
99 ServerConfig as T3ServerConfig,
10 UsageLimitsReport,
1011} from "@t3tools/contracts";
12import {
13 collectProviderUsageLimits,
14 hasProviderUsageLimits,
15 isUsageLimitsCommand,
16} from "@t3tools/shared/usageLimits";
1117import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";
1218import type { ReactNode } from "react";
1319import {
......@@ -20,7 +26,7 @@ import {
2026 useState,
2127 type RefObject,
2228} from "react";
23import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native";
29import { ActivityIndicator, Alert, Platform, Pressable, View, type ViewStyle } from "react-native";
2430import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
2531import {
2632 composerAttachmentUploadBlockReason,
......@@ -124,6 +130,8 @@ export interface ThreadComposerProps {
124130 readonly onRemoveDraftImage: (imageId: string) => void;
125131 readonly onStopThread: () => void;
126132 readonly onSendMessage: () => Promise<MessageId | null>;
133 /** `/usage-limits` resolves locally; the host decides where the report shows. Null clears it. */
134 readonly onShowUsageLimits: (report: UsageLimitsReport | null) => void;
127135 readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void;
128136 readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void;
129137 readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void;
......@@ -336,6 +344,30 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
336344 );
337345 }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]);
338346 const composerOwnerKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
347 const { onSendMessage, onChangeDraftMessage, onShowUsageLimits } = props;
348 // T3 owns /usage-limits only where Limits has data for the selected provider;
349 // elsewhere the name stays the provider's own and is sent through untouched.
350 const usageLimitsOffered =
351 selectedProviderStatus !== null &&
352 hasProviderUsageLimits(
353 selectedProviderStatus.driver,
354 props.serverConfig?.providers ?? [],
355 props.serverConfig?.usageLimitSources ?? [],
356 );
357 // Answered locally from the last Limits snapshot; the agent never sees it.
358 const openUsageLimits = useCallback(() => {
359 const report = collectProviderUsageLimits(
360 currentModelSelection.instanceId,
361 props.serverConfig?.providers ?? [],
362 props.serverConfig?.usageLimitSources ?? [],
363 Date.now(),
364 );
365 onShowUsageLimits(report);
366 if (!report) {
367 Alert.alert("Usage limits unavailable", "This provider does not currently report limits.");
368 }
369 return report !== null;
370 }, [currentModelSelection.instanceId, onShowUsageLimits, props.serverConfig]);
339371
340372 const composerMenu = useComposerCommandMenu({
341373 draftMessage: props.draftMessage,
......@@ -350,6 +382,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
350382 selectedProviderStatus?.showInteractionModeToggle === false
351383 ? undefined
352384 : props.onUpdateInteractionMode,
385 offersUsageLimits: usageLimitsOffered,
386 // With attachments aboard the pick just inserts the text, so it sends as a prompt.
387 onUsageLimits:
388 usageLimitsOffered && props.draftAttachments.length === 0 ? openUsageLimits : undefined,
353389 });
354390 const voiceInput = useVoiceInputController({
355391 ownerKey: composerOwnerKey,
......@@ -428,9 +464,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
428464 }
429465 onEditorFocusChange?.(false);
430466 }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]);
431 const { onSendMessage } = props;
432
433467 const handleSend = useCallback(async () => {
468 // Typed out in full rather than picked from the menu. Attachments mean the
469 // user is sending a prompt, so those go through as usual.
470 if (
471 usageLimitsOffered &&
472 isUsageLimitsCommand(props.draftMessage) &&
473 props.draftAttachments.length === 0
474 ) {
475 if (openUsageLimits()) onChangeDraftMessage("");
476 return;
477 }
434478 if (voiceInput.blocksSubmission) return;
435479 const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
436480 if (inFlightThreadIdsRef.current.has(threadKey)) return;
......@@ -453,6 +497,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
453497 inFlightThreadIdsRef.current.delete(threadKey);
454498 }
455499 }, [
500 props.draftMessage,
501 props.draftAttachments.length,
502 onChangeDraftMessage,
503 openUsageLimits,
504 usageLimitsOffered,
456505 onSendMessage,
457506 props.environmentId,
458507 props.environmentLabel,
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+83
......@@ -19,6 +19,7 @@ import type {
1919 RuntimeMode,
2020 ServerConfig as T3ServerConfig,
2121 ThreadId,
22 UsageLimitsReport,
2223 UserInputQuestion,
2324} from "@t3tools/contracts";
2425import * as Haptics from "expo-haptics";
......@@ -57,6 +58,7 @@ import Animated, {
5758import { useSafeAreaInsets } from "react-native-safe-area-context";
5859
5960import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
61import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits";
6062import type { ComposerEditorHandle } from "../../components/ComposerEditor";
6163import type { StatusTone } from "../../components/StatusPill";
6264import type { DraftComposerAttachment } from "../../lib/composerImages";
......@@ -70,6 +72,7 @@ import type {
7072 ThreadFeedEntry,
7173} from "../../lib/threadActivity";
7274import { PendingApprovalCard } from "./PendingApprovalCard";
75import { ComposerUsageLimits } from "./ComposerUsageLimits";
7376import { PendingUserInputCard } from "./PendingUserInputCard";
7477import {
7578 FLOATING_WORKING_CONTROL_COVERAGE,
......@@ -359,6 +362,68 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
359362 const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] =
360363 useState<ApprovalRequestId | null>(null);
361364 const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null;
365 // The open /usage-limits panel for this thread, model and turn. Only the open
366 // moment is stored: the rows read live provider data, so a redeemed reset
367 // credit or refreshed probe shows through. Anything that spends quota closes
368 // it: a new turn from any source, or the agent resuming after an approval or
369 // answered question.
370 const [usageLimitsPanel, setUsageLimitsPanel] = useState<{
371 readonly key: string;
372 readonly threadKey: string;
373 readonly now: number;
374 } | null>(null);
375 // A pending approval or question is part of the key: once it is answered,
376 // from this client or any other, the agent resumes and spends quota.
377 const usageLimitsKey = [
378 selectedThreadKey,
379 props.selectedThread.modelSelection.instanceId,
380 props.selectedThread.latestTurn?.turnId ?? "",
381 props.activePendingApproval?.requestId ?? props.activePendingUserInput?.requestId ?? "",
382 ].join(":");
383 // Drop the snapshot as soon as the key changes so it cannot resurface stale.
384 if (usageLimitsPanel !== null && usageLimitsPanel.key !== usageLimitsKey) {
385 setUsageLimitsPanel(null);
386 }
387 const usageLimitsReport = useMemo(
388 () =>
389 usageLimitsPanel !== null && usageLimitsPanel.key === usageLimitsKey
390 ? collectProviderUsageLimits(
391 props.selectedThread.modelSelection.instanceId,
392 props.serverConfig?.providers ?? [],
393 props.serverConfig?.usageLimitSources ?? [],
394 usageLimitsPanel.now,
395 )
396 : null,
397 [
398 props.selectedThread.modelSelection.instanceId,
399 props.serverConfig,
400 usageLimitsKey,
401 usageLimitsPanel,
402 ],
403 );
404 const showUsageLimits = useCallback(
405 (report: UsageLimitsReport | null) =>
406 setUsageLimitsPanel(
407 report === null
408 ? null
409 : {
410 key: usageLimitsKey,
411 threadKey: selectedThreadKey,
412 now: Date.parse(report.createdAt),
413 },
414 ),
415 [selectedThreadKey, usageLimitsKey],
416 );
417 const dismissUsageLimits = useCallback(() => setUsageLimitsPanel(null), []);
418 // A send may resolve after navigating away, so only the originating
419 // thread's panel is cleared; a panel opened elsewhere in the meantime stays.
420 const clearUsageLimitsFor = useCallback(
421 (threadKey: string) =>
422 setUsageLimitsPanel((current) =>
423 current !== null && current.threadKey === threadKey ? null : current,
424 ),
425 [],
426 );
362427 const userInputCollapsed =
363428 activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId;
364429 // The card's height RESERVES keyboard space at all times instead of
......@@ -623,6 +688,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
623688 return messageId;
624689 }
625690
691 // A sent message makes the snapshot stale; a refused send leaves it in place.
692 clearUsageLimitsFor(targetThreadKey);
693
626694 setSubmittedMessageId(messageId);
627695 setAnchorMessageId(
628696 resolveThreadFeedSubmissionAnchor({
......@@ -637,6 +705,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
637705 return messageId;
638706 }, [
639707 anchorMessageId,
708 clearUsageLimitsFor,
640709 props.onSendMessage,
641710 props.selectedThread.latestTurn,
642711 props.selectedThreadQueueCount,
......@@ -778,6 +847,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
778847 onScrollToEnd={handleScrollToEnd}
779848 />
780849 <View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>
850 {usageLimitsReport && activeUserInputRequestId === null ? (
851 <Animated.View
852 className="shrink-0 px-4 pb-3"
853 entering={FadeInDown.duration(220)}
854 exiting={FadeOut.duration(140)}
855 >
856 <ComposerUsageLimits
857 report={usageLimitsReport}
858 environmentId={props.environmentId}
859 onClose={dismissUsageLimits}
860 />
861 </Animated.View>
862 ) : null}
781863 {props.activePendingApproval || props.activePendingUserInput ? (
782864 <Animated.View
783865 className="shrink-0 gap-3 px-4 pb-3"
......@@ -846,6 +928,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
846928 onRemoveDraftImage={props.onRemoveDraftImage}
847929 onStopThread={props.onStopThread}
848930 onSendMessage={handleSendMessage}
931 onShowUsageLimits={showUsageLimits}
849932 onReconnectEnvironment={props.onReconnectEnvironment}
850933 onUpdateModelSelection={props.onUpdateThreadModelSelection}
851934 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
apps/mobile/src/features/threads/use-composer-command-menu.ts+29
......@@ -1,4 +1,5 @@
11import type { EnvironmentId, ProviderInteractionMode, ServerProvider } from "@t3tools/contracts";
2import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";
23import {
34 detectComposerTrigger,
45 replaceTextRange,
......@@ -36,6 +37,8 @@ export function buildComposerSlashCommandItems(input: {
3637 readonly atMessageStart: boolean;
3738 readonly hasThread: boolean;
3839 readonly hasCompactableConversation?: boolean;
40 /** Whether T3 itself offers /usage-limits for the selected provider. */
41 readonly offersUsageLimits?: boolean;
3942 readonly allowInteractionMode: boolean;
4043 readonly selectedProviderStatus: Pick<
4144 ServerProvider,
......@@ -78,6 +81,11 @@ export function buildComposerSlashCommandItems(input: {
7881 for (const command of input.selectedProviderStatus?.slashCommands ?? []) {
7982 if (!command.name.toLowerCase().includes(query)) continue;
8083 if (command.name === "compact" && !input.hasCompactableConversation) continue;
84 // T3's own limits command is answered by the thread composer; New Task has
85 // nowhere to show it. A provider's same-named command is left alone.
86 if (command.name === USAGE_LIMITS_COMMAND.name && input.offersUsageLimits && !input.hasThread) {
87 continue;
88 }
8189 if (
8290 !input.hasThread &&
8391 input.selectedProviderStatus?.driver === "codex" &&
......@@ -143,9 +151,11 @@ export function useComposerCommandMenu({
143151 selectedProviderStatus,
144152 hasThread,
145153 hasCompactableConversation,
154 offersUsageLimits = false,
146155 enabled = true,
147156 onChangeDraftMessage,
148157 onUpdateInteractionMode,
158 onUsageLimits,
149159}: {
150160 readonly draftMessage: string;
151161 readonly ownerKey: string | null;
......@@ -154,9 +164,13 @@ export function useComposerCommandMenu({
154164 readonly selectedProviderStatus: ServerProvider | null;
155165 readonly hasThread: boolean;
156166 readonly hasCompactableConversation: boolean;
167 /** Whether T3 itself offers /usage-limits for the selected provider. */
168 readonly offersUsageLimits?: boolean;
157169 readonly enabled?: boolean;
158170 readonly onChangeDraftMessage: (value: string) => void;
159171 readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void;
172 /** Picking /usage-limits is the action itself; the draft keeps nothing of it. */
173 readonly onUsageLimits?: () => void;
160174}) {
161175 const [selection, setSelection] = useState(() => composerSelectionAtEnd(draftMessage));
162176 const previousOwnerKeyRef = useRef(ownerKey);
......@@ -267,6 +281,7 @@ export function useComposerCommandMenu({
267281 atMessageStart: trigger.rangeStart === 0,
268282 hasThread,
269283 hasCompactableConversation,
284 offersUsageLimits,
270285 allowInteractionMode: onUpdateInteractionMode !== undefined,
271286 selectedProviderStatus,
272287 });
......@@ -390,12 +405,25 @@ export function useComposerCommandMenu({
390405 selectedProviderStatus,
391406 skills,
392407 trigger,
408 offersUsageLimits,
393409 ]);
394410
395411 const onSelect = useCallback(
396412 (item: ComposerCommandItem) => {
397413 if (!trigger) return;
398414
415 if (
416 item.type === "provider-slash-command" &&
417 item.command.name === USAGE_LIMITS_COMMAND.name &&
418 onUsageLimits
419 ) {
420 const cleared = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, "");
421 setSelection({ start: cleared.cursor, end: cleared.cursor });
422 onChangeDraftMessage(cleared.text);
423 onUsageLimits();
424 return;
425 }
426
399427 const result = resolveComposerCommandSelection({
400428 draftMessage,
401429 trigger,
......@@ -414,6 +442,7 @@ export function useComposerCommandMenu({
414442 draftMessage,
415443 onChangeDraftMessage,
416444 onUpdateInteractionMode,
445 onUsageLimits,
417446 selectedProviderStatus?.showInteractionModeToggle,
418447 trigger,
419448 ],
apps/mobile/src/features/usage/UsageLimitsSection.tsx+32-10
......@@ -69,9 +69,9 @@ function WindowRow(props: {
6969 <View
7070 className={
7171 used >= 90
72 ? "h-full rounded-full bg-destructive"
72 ? "h-full rounded-full bg-red-500"
7373 : used >= 70
74 ? "h-full rounded-full bg-warning"
74 ? "h-full rounded-full bg-amber-500"
7575 : "h-full rounded-full bg-foreground"
7676 }
7777 style={[
......@@ -99,7 +99,7 @@ function WindowRow(props: {
9999}
100100
101101/** One account: icon, name and plan on a single line, then its windows. */
102function AccountLimits(props: {
102export function AccountLimits(props: {
103103 readonly driver: Driver;
104104 readonly label: string;
105105 readonly instanceLabel: string;
......@@ -107,14 +107,23 @@ function AccountLimits(props: {
107107 readonly limits: ServerProvider["usageLimits"];
108108 readonly now: number;
109109 readonly first: boolean;
110 /** Tighter padding for the composer card. */
111 readonly dense?: boolean;
112 /** Sits at the end of the heading row, such as a close control. */
113 readonly trailing?: ReactNode;
110114 readonly footer?: ReactNode;
111115}) {
112 const { limits, now } = props;
116 const { limits, now, dense = false } = props;
113117 const color = useBarColor(props.driver);
114118 if (!limits) return null;
115119 const notice = limitsNotice(limits);
120 const padding = dense ? "px-4 py-3" : "p-4";
116121 return (
117 <View className={props.first ? "gap-3 p-4" : "gap-3 border-t border-border-subtle p-4"}>
122 <View
123 className={
124 props.first ? `gap-3 ${padding}` : `gap-3 border-t border-border-subtle ${padding}`
125 }
126 >
118127 <View className="flex-row items-center gap-2">
119128 <ProviderIcon provider={props.driver} size={16} />
120129 <View className="min-w-0 flex-1 flex-row items-baseline gap-2">
......@@ -130,6 +139,7 @@ function AccountLimits(props: {
130139 </Text>
131140 ) : null}
132141 </View>
142 {props.trailing}
133143 </View>
134144 {notice ? (
135145 <Text className="text-sm text-foreground-muted">{notice}</Text>
......@@ -157,13 +167,15 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {
157167 * credit the provider granted the user, so it goes through the native
158168 * confirm alert rather than firing on a bare tap.
159169 */
160function ResetCredits(props: {
170export function ResetCredits(props: {
161171 readonly environmentId: EnvironmentId;
162172 readonly instanceId: ProviderInstanceId;
163173 readonly credits: ServerProviderResetCredits;
164174 readonly now: number;
175 /** A smaller pill for the composer card. */
176 readonly dense?: boolean;
165177}) {
166 const { environmentId, instanceId, credits, now } = props;
178 const { environmentId, instanceId, credits, now, dense = false } = props;
167179 const consume = useAtomCommand(serverEnvironment.consumeResetCredit, {
168180 reportFailure: false,
169181 });
......@@ -217,10 +229,20 @@ function ResetCredits(props: {
217229 accessibilityState={{ disabled: busy }}
218230 disabled={busy}
219231 onPress={confirm}
220 className="rounded-full bg-subtle-strong px-3 py-1.5"
232 className={
233 dense
234 ? "rounded-full bg-subtle-strong px-2.5 py-1"
235 : "rounded-full bg-subtle-strong px-3 py-1.5"
236 }
221237 >
222 <Text className="text-sm font-t3-medium text-foreground">
223 {busy ? "Using credit…" : "Use a reset credit"}
238 <Text
239 className={
240 dense
241 ? "text-xs font-t3-medium text-foreground"
242 : "text-sm font-t3-medium text-foreground"
243 }
244 >
245 {busy ? "Using…" : "Use reset"}
224246 </Text>
225247 </Pressable>
226248 ) : null}
apps/mobile/src/state/server.ts+1
......@@ -8,6 +8,7 @@ import { environmentSession } from "./session";
88export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, {
99 initialConfigValueAtom: environmentSession.initialConfigValueAtom,
1010 usageLimitSources: true,
11 usageLimitsCommand: true,
1112});
1213export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({
1314 catalogValueAtom: environmentCatalog.catalogValueAtom,
apps/server/src/server.test.ts+211-38
......@@ -39,6 +39,7 @@ import {
3939 type ServerLifecycleStreamEvent,
4040 ThreadId,
4141 TurnId,
42 UsageLimitSourceId,
4243 WS_METHODS,
4344 WsRpcGroup,
4445 EditorId,
......@@ -498,6 +499,7 @@ const buildAppUnderTest = (options?: {
498499 keybindings?: Partial<Keybindings.Keybindings["Service"]>;
499500 environmentTheme?: Partial<EnvironmentTheme.EnvironmentThemeService["Service"]>;
500501 providerRegistry?: Partial<ProviderRegistry.ProviderRegistry["Service"]>;
502 usageLimitSources?: Partial<UsageLimitSources.UsageLimitSources["Service"]>;
501503 providerService?: Partial<ProviderService.ProviderService["Service"]>;
502504 providerAuth?: Partial<ProviderAuthService["Service"]>;
503505 providerInstanceRegistry?: Partial<ProviderInstanceRegistry["Service"]>;
......@@ -756,8 +758,9 @@ const buildAppUnderTest = (options?: {
756758 }),
757759 Layer.mock(UsageLimitSources.UsageLimitSources)({
758760 current: Effect.succeed([]),
759 streamChanges: Stream.empty,
761 streamChanges: Stream.make([]),
760762 refresh: Effect.void,
763 ...options?.layers?.usageLimitSources,
761764 }),
762765 ),
763766 ),
......@@ -6246,10 +6249,94 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
62466249 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
62476250 );
62486251
6249 it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () =>
6250 Effect.gen(function* () {
6251 const nextProviders = [
6252 {
6252 it.effect.each([false, true])(
6253 "routes websocket rpc subscribeServerConfig emits provider status updates (limits: %s)",
6254 (hasLimits) =>
6255 Effect.gen(function* () {
6256 const nextProviders = [
6257 {
6258 instanceId: ProviderInstanceId.make("codex"),
6259 driver: ProviderDriverKind.make("codex"),
6260 enabled: true,
6261 installed: true,
6262 version: "1.0.0",
6263 status: "ready" as const,
6264 auth: { status: "authenticated" as const },
6265 checkedAt: "2026-04-11T00:00:00.000Z",
6266 models: [],
6267 slashCommands: [],
6268 skills: [],
6269 ...(hasLimits
6270 ? {
6271 usageLimits: {
6272 checkedAt: "2026-04-11T00:00:00.000Z",
6273 windows: [
6274 { id: "weekly", kind: "weekly" as const, label: "Weekly", usedPercent: 25 },
6275 ],
6276 },
6277 }
6278 : {}),
6279 },
6280 ] as const;
6281
6282 yield* buildAppUnderTest({
6283 layers: {
6284 keybindings: {
6285 loadConfigState: Effect.succeed({
6286 keybindings: [],
6287 issues: [],
6288 }),
6289 streamChanges: Stream.empty,
6290 },
6291 providerRegistry: {
6292 getProviders: Effect.succeed([]),
6293 streamChanges: Stream.succeed(nextProviders),
6294 },
6295 },
6296 });
6297
6298 const wsUrl = yield* getWsServerUrl("/ws");
6299 const events = yield* Effect.scoped(
6300 withWsRpcClient(wsUrl, (client) =>
6301 client[WS_METHODS.subscribeServerConfig]({ usageLimitsCommand: true }).pipe(
6302 Stream.take(2),
6303 Stream.runCollect,
6304 ),
6305 ),
6306 );
6307
6308 const [first, second] = Array.from(events);
6309 assert.equal(first?.type, "snapshot");
6310 if (first?.type === "snapshot") {
6311 assert.deepEqual(first.config.providers, []);
6312 }
6313 assert.deepEqual(second, {
6314 version: 1,
6315 type: "providerStatuses",
6316 payload: {
6317 providers: hasLimits
6318 ? [
6319 {
6320 ...nextProviders[0],
6321 slashCommands: [
6322 {
6323 name: "usage-limits",
6324 description: "Show this provider's usage limits",
6325 },
6326 ],
6327 },
6328 ]
6329 : nextProviders,
6330 },
6331 });
6332 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
6333 );
6334
6335 it.effect(
6336 "routes websocket rpc subscribeServerConfig keeps the limits command from clients that do not ask for it",
6337 () =>
6338 Effect.gen(function* () {
6339 const codex = {
62536340 instanceId: ProviderInstanceId.make("codex"),
62546341 driver: ProviderDriverKind.make("codex"),
62556342 enabled: true,
......@@ -6261,43 +6348,129 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
62616348 models: [],
62626349 slashCommands: [],
62636350 skills: [],
6264 },
6265 ] as const;
6266
6267 yield* buildAppUnderTest({
6268 layers: {
6269 keybindings: {
6270 loadConfigState: Effect.succeed({
6271 keybindings: [],
6272 issues: [],
6273 }),
6274 streamChanges: Stream.empty,
6351 usageLimits: {
6352 checkedAt: "2026-04-11T00:00:00.000Z",
6353 windows: [{ id: "weekly", kind: "weekly" as const, label: "Weekly", usedPercent: 25 }],
62756354 },
6276 providerRegistry: {
6277 getProviders: Effect.succeed([]),
6278 streamChanges: Stream.succeed(nextProviders),
6355 };
6356 yield* buildAppUnderTest({
6357 layers: {
6358 keybindings: {
6359 loadConfigState: Effect.succeed({ keybindings: [], issues: [] }),
6360 streamChanges: Stream.empty,
6361 },
6362 providerRegistry: {
6363 getProviders: Effect.succeed([codex]),
6364 streamChanges: Stream.succeed([{ ...codex, version: "1.0.1" }]),
6365 },
62796366 },
6280 },
6281 });
6367 });
62826368
6283 const wsUrl = yield* getWsServerUrl("/ws");
6284 const events = yield* Effect.scoped(
6285 withWsRpcClient(wsUrl, (client) =>
6286 client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect),
6287 ),
6288 );
6369 const wsUrl = yield* getWsServerUrl("/ws");
6370 const events = yield* Effect.scoped(
6371 withWsRpcClient(wsUrl, (client) =>
6372 client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect),
6373 ),
6374 );
62896375
6290 const [first, second] = Array.from(events);
6291 assert.equal(first?.type, "snapshot");
6292 if (first?.type === "snapshot") {
6293 assert.deepEqual(first.config.providers, []);
6294 }
6295 assert.deepEqual(second, {
6296 version: 1,
6297 type: "providerStatuses",
6298 payload: { providers: nextProviders },
6299 });
6300 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
6376 const [first, second] = Array.from(events);
6377 assert.equal(first?.type, "snapshot");
6378 if (first?.type === "snapshot") {
6379 assert.deepEqual(first.config.providers, [codex]);
6380 }
6381 assert.deepEqual(second, {
6382 version: 1,
6383 type: "providerStatuses",
6384 payload: { providers: [{ ...codex, version: "1.0.1" }] },
6385 });
6386 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
6387 );
6388
6389 it.effect(
6390 "routes websocket rpc subscribeServerConfig republishes commands when only a limits source changes",
6391 () =>
6392 Effect.gen(function* () {
6393 const codex = {
6394 instanceId: ProviderInstanceId.make("codex"),
6395 driver: ProviderDriverKind.make("codex"),
6396 enabled: true,
6397 installed: true,
6398 version: "1.0.0",
6399 status: "ready" as const,
6400 auth: { status: "authenticated" as const },
6401 checkedAt: "2026-04-11T00:00:00.000Z",
6402 models: [],
6403 slashCommands: [],
6404 skills: [],
6405 };
6406 const hub = {
6407 id: UsageLimitSourceId.make("hub"),
6408 kind: "cliproxy" as const,
6409 label: "Accounts",
6410 checkedAt: "2026-04-11T00:00:00.000Z",
6411 accounts: [
6412 {
6413 id: "work",
6414 driver: ProviderDriverKind.make("codex"),
6415 usageLimits: {
6416 checkedAt: "2026-04-11T00:00:00.000Z",
6417 windows: [
6418 { id: "weekly", kind: "weekly" as const, label: "Weekly", usedPercent: 25 },
6419 ],
6420 },
6421 },
6422 ],
6423 };
6424
6425 yield* buildAppUnderTest({
6426 layers: {
6427 keybindings: {
6428 loadConfigState: Effect.succeed({ keybindings: [], issues: [] }),
6429 streamChanges: Stream.empty,
6430 },
6431 // The registry emits no change: only the source refresh can carry it.
6432 providerRegistry: {
6433 getProviders: Effect.succeed([codex]),
6434 streamChanges: Stream.empty,
6435 },
6436 usageLimitSources: {
6437 current: Effect.succeed([]),
6438 // Replay the empty snapshot, then a later refresh, as the live stream does.
6439 streamChanges: Stream.concat(Stream.make([]), Stream.make([hub])),
6440 },
6441 },
6442 });
6443
6444 const wsUrl = yield* getWsServerUrl("/ws");
6445 const events = yield* Effect.scoped(
6446 withWsRpcClient(wsUrl, (client) =>
6447 client[WS_METHODS.subscribeServerConfig]({ usageLimitsCommand: true }).pipe(
6448 Stream.take(2),
6449 Stream.runCollect,
6450 ),
6451 ),
6452 );
6453
6454 const [first, second] = Array.from(events);
6455 assert.equal(first?.type, "snapshot");
6456 if (first?.type === "snapshot") {
6457 assert.deepEqual(first.config.providers, [codex]);
6458 }
6459 assert.deepEqual(second, {
6460 version: 1,
6461 type: "providerStatuses",
6462 payload: {
6463 providers: [
6464 {
6465 ...codex,
6466 slashCommands: [
6467 { name: "usage-limits", description: "Show this provider's usage limits" },
6468 ],
6469 },
6470 ],
6471 },
6472 });
6473 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
63016474 );
63026475
63036476 it.effect(
apps/server/src/ws.ts+102-62
......@@ -1,3 +1,7 @@
1import {
2 sameUsageLimitCommandCoverage,
3 withUsageLimitsCommands,
4} from "@t3tools/shared/usageLimits";
15import * as Cause from "effect/Cause";
26import * as Crypto from "effect/Crypto";
37import * as DateTime from "effect/DateTime";
......@@ -1221,59 +1225,67 @@ const makeWsRpcLayer = (
12211225 );
12221226 };
12231227
1224 const loadServerConfig = Effect.gen(function* () {
1225 const keybindingsConfig = yield* keybindings.loadConfigState;
1226 const providers = yield* providerRegistry.getProviders;
1227 const settings = ServerSettings.redactServerSettingsForClient(
1228 yield* serverSettings.getSettings,
1229 );
1230 const environment = yield* serverEnvironment.getDescriptor;
1231 const auth = yield* serverAuth.getDescriptor();
1232 const availableEditors: ReadonlyArray<EditorId> = yield* resolveAvailableEditorsForConfig(
1233 externalLauncher.resolveAvailableEditors(),
1234 );
1235 const fileManagerRevealKind = availableEditors.includes("file-manager")
1236 ? yield* resolveFileManagerRevealKindForConfig(
1237 externalLauncher.resolveFileManagerRevealKind(),
1238 )
1239 : undefined;
1240
1241 return {
1242 environment,
1243 auth,
1244 cwd: config.cwd,
1245 keybindingsConfigPath: config.keybindingsConfigPath,
1246 keybindings: keybindingsConfig.keybindings,
1247 issues: keybindingsConfig.issues,
1248 providers,
1249 availableEditors,
1250 // Same discovery-with-timeout treatment as editors: a slow probe
1251 // must not stall server.getConfig, so it degrades to no targets.
1252 remoteOpenTargets: yield* resolveAvailableEditorsForConfig(
1253 remoteOpenTargets.resolveTargets(),
1254 ),
1255 observability: {
1256 logsDirectoryPath: config.logsDir,
1257 localTracingEnabled: true,
1258 ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}),
1259 otlpTracesEnabled: config.otlpTracesUrl !== undefined,
1260 ...(config.otlpMetricsUrl !== undefined
1261 ? { otlpMetricsUrl: config.otlpMetricsUrl }
1262 : {}),
1263 otlpMetricsEnabled: config.otlpMetricsUrl !== undefined,
1264 },
1265 settings,
1266 shellResumeCompletionMarker: true,
1267 ...(fileManagerRevealKind === undefined
1268 ? {}
1269 : {
1270 shellRevealInFileManager: true,
1271 shellRevealInFileManagerKind: fileManagerRevealKind,
1272 }),
1273 threadResumeCompletionMarker: true,
1274 threadSnapshotPagination: true,
1275 };
1276 });
1228 // Only clients that answer /usage-limits themselves see it in the catalogs;
1229 // an older client would send the injected command to the provider.
1230 const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) =>
1231 Effect.gen(function* () {
1232 const keybindingsConfig = yield* keybindings.loadConfigState;
1233 const currentProviders = yield* providerRegistry.getProviders;
1234 const providers = options.usageLimitsCommand
1235 ? withUsageLimitsCommands(currentProviders, yield* usageLimitSources.current)
1236 : currentProviders;
1237 const settings = ServerSettings.redactServerSettingsForClient(
1238 yield* serverSettings.getSettings,
1239 );
1240 const environment = yield* serverEnvironment.getDescriptor;
1241 const auth = yield* serverAuth.getDescriptor();
1242 const availableEditors: ReadonlyArray<EditorId> = yield* resolveAvailableEditorsForConfig(
1243 externalLauncher.resolveAvailableEditors(),
1244 );
1245 const fileManagerRevealKind = availableEditors.includes("file-manager")
1246 ? yield* resolveFileManagerRevealKindForConfig(
1247 externalLauncher.resolveFileManagerRevealKind(),
1248 )
1249 : undefined;
1250
1251 return {
1252 environment,
1253 auth,
1254 cwd: config.cwd,
1255 keybindingsConfigPath: config.keybindingsConfigPath,
1256 keybindings: keybindingsConfig.keybindings,
1257 issues: keybindingsConfig.issues,
1258 providers,
1259 availableEditors,
1260 // Same discovery-with-timeout treatment as editors: a slow probe
1261 // must not stall server.getConfig, so it degrades to no targets.
1262 remoteOpenTargets: yield* resolveAvailableEditorsForConfig(
1263 remoteOpenTargets.resolveTargets(),
1264 ),
1265 observability: {
1266 logsDirectoryPath: config.logsDir,
1267 localTracingEnabled: true,
1268 ...(config.otlpTracesUrl !== undefined
1269 ? { otlpTracesUrl: config.otlpTracesUrl }
1270 : {}),
1271 otlpTracesEnabled: config.otlpTracesUrl !== undefined,
1272 ...(config.otlpMetricsUrl !== undefined
1273 ? { otlpMetricsUrl: config.otlpMetricsUrl }
1274 : {}),
1275 otlpMetricsEnabled: config.otlpMetricsUrl !== undefined,
1276 },
1277 settings,
1278 shellResumeCompletionMarker: true,
1279 ...(fileManagerRevealKind === undefined
1280 ? {}
1281 : {
1282 shellRevealInFileManager: true,
1283 shellRevealInFileManagerKind: fileManagerRevealKind,
1284 }),
1285 threadResumeCompletionMarker: true,
1286 threadSnapshotPagination: true,
1287 };
1288 });
12771289
12781290 const refreshGitStatus = (cwd: string) =>
12791291 vcsStatusBroadcaster
......@@ -1750,9 +1762,13 @@ const makeWsRpcLayer = (
17501762 "rpc.aggregate": "server",
17511763 }),
17521764 [WS_METHODS.serverGetConfig]: (_input) =>
1753 observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, {
1754 "rpc.aggregate": "server",
1755 }),
1765 observeRpcEffect(
1766 WS_METHODS.serverGetConfig,
1767 loadServerConfig({ usageLimitsCommand: false }),
1768 {
1769 "rpc.aggregate": "server",
1770 },
1771 ),
17561772 [WS_METHODS.serverRefreshProviders]: (input) =>
17571773 observeRpcEffect(
17581774 WS_METHODS.serverRefreshProviders,
......@@ -2692,6 +2708,8 @@ const makeWsRpcLayer = (
26922708 observeRpcStreamEffect(
26932709 WS_METHODS.subscribeServerConfig,
26942710 Effect.gen(function* () {
2711 const usageLimitsCommand = input.usageLimitsCommand === true;
2712 const config = yield* loadServerConfig({ usageLimitsCommand });
26952713 const keybindingsUpdates = keybindings.streamChanges.pipe(
26962714 Stream.map((event) => ({
26972715 version: 1 as const,
......@@ -2702,7 +2720,33 @@ const makeWsRpcLayer = (
27022720 },
27032721 })),
27042722 );
2705 const providerStatuses = providerRegistry.streamChanges.pipe(
2723 const providerStatuses = Stream.zipLatestWith(
2724 // The registry stream carries changes only. Seed it with the current
2725 // providers so a source refresh that lands before any provider change
2726 // still pairs up and reaches the client.
2727 Stream.concat(
2728 Stream.fromEffect(providerRegistry.getProviders),
2729 providerRegistry.streamChanges,
2730 ),
2731 usageLimitSources.streamChanges.pipe(
2732 // Quota updates already have their own stream. Republish the model
2733 // catalog only when the set of providers offered the command changes.
2734 Stream.changesWith(
2735 usageLimitsCommand ? sameUsageLimitCommandCoverage : () => true,
2736 ),
2737 ),
2738 (providers, sources) =>
2739 usageLimitsCommand ? withUsageLimitsCommands(providers, sources) : providers,
2740 ).pipe(
2741 // Both sides replay their current value, so the first pairing normally
2742 // repeats the snapshot the client already holds. Compare against that
2743 // snapshot rather than dropping blindly: a refresh that landed between
2744 // the snapshot and the subscription still goes out.
2745 (updates) => Stream.concat(Stream.make(config.providers), updates),
2746 Stream.changesWith(
2747 (previous, next) => JSON.stringify(previous) === JSON.stringify(next),
2748 ),
2749 Stream.drop(1),
27062750 Stream.map((providers) => ({
27072751 version: 1 as const,
27082752 type: "providerStatuses" as const,
......@@ -2763,11 +2807,7 @@ const makeWsRpcLayer = (
27632807 );
27642808
27652809 return Stream.concat(
2766 Stream.make({
2767 version: 1 as const,
2768 type: "snapshot" as const,
2769 config: yield* loadServerConfig,
2770 }),
2810 Stream.make({ version: 1 as const, type: "snapshot" as const, config }),
27712811 liveUpdates,
27722812 );
27732813 }),
apps/web/src/components/ChatView.tsx+156
......@@ -1,3 +1,10 @@
1import type { UsageLimitSourceSnapshots } from "@t3tools/contracts";
2import {
3 collectProviderUsageLimits,
4 hasProviderUsageLimits,
5 isUsageLimitsCommand,
6} from "@t3tools/shared/usageLimits";
7import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits";
18import {
29 type AssistantCitation,
310 type ApprovalRequestId,
......@@ -451,6 +458,7 @@ import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "./chat/composerPromptHistory";
451458
452459const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
453460const EMPTY_PROVIDERS: ServerProvider[] = [];
461const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = [];
454462const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
455463const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
456464function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {
......@@ -1505,6 +1513,11 @@ export default function ChatView(props: ChatViewProps) {
15051513 const draft = store.getComposerDraft(composerDraftTarget);
15061514 return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0;
15071515 });
1516 // Anything beyond the prompt text: attachments, terminal or element contexts, annotations.
1517 const composerHasNonPromptContent = useComposerDraftStore((store) => {
1518 const draft = store.getComposerDraft(composerDraftTarget);
1519 return draft ? composerDraftHasUserContent({ ...draft, prompt: "" }) : false;
1520 });
15081521 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
15091522 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
15101523 const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles);
......@@ -2620,6 +2633,111 @@ export default function ChatView(props: ChatViewProps) {
26202633 hasComposerAttachments: composerHasAttachments,
26212634 });
26222635 const activePendingApproval = pendingApprovals[0] ?? null;
2636 // The open /usage-limits panel for this thread, model and turn. Only the open
2637 // moment is stored: the rows read live provider data, so a redeemed reset
2638 // credit or refreshed probe shows through. Anything that spends quota closes
2639 // it: a new turn from any source, or the agent resuming after an approval or
2640 // answered question.
2641 const [usageLimitsPanel, setUsageLimitsPanel] = useState<{
2642 readonly key: string;
2643 readonly threadKey: string;
2644 readonly now: number;
2645 } | null>(null);
2646 // Null while the provider list or the thread itself is unavailable, such as
2647 // during a reconnect; the panel then stays hidden rather than being dropped.
2648 // A pending approval or question is part of the key: once it is answered,
2649 // from this client or any other, the agent resumes and spends quota.
2650 const usageLimitsKey =
2651 activeProviderInstanceId === null || (isServerThread && activeThread === undefined)
2652 ? null
2653 : [
2654 routeThreadKey,
2655 activeProviderInstanceId,
2656 activeThread?.latestTurn?.turnId ?? "",
2657 activePendingApproval?.requestId ?? activePendingUserInput?.requestId ?? "",
2658 ].join(":");
2659 // Drop the snapshot as soon as the thread or model changes so it cannot resurface stale.
2660 if (
2661 usageLimitsPanel !== null &&
2662 usageLimitsKey !== null &&
2663 usageLimitsPanel.key !== usageLimitsKey
2664 ) {
2665 setUsageLimitsPanel(null);
2666 }
2667 const usageLimitSources = serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES;
2668 const usageLimitsReport = useMemo(
2669 () =>
2670 usageLimitsPanel !== null &&
2671 usageLimitsKey !== null &&
2672 usageLimitsPanel.key === usageLimitsKey &&
2673 activeProviderInstanceId !== null
2674 ? collectProviderUsageLimits(
2675 activeProviderInstanceId,
2676 providerStatuses,
2677 usageLimitSources,
2678 usageLimitsPanel.now,
2679 )
2680 : null,
2681 [
2682 activeProviderInstanceId,
2683 providerStatuses,
2684 usageLimitSources,
2685 usageLimitsKey,
2686 usageLimitsPanel,
2687 ],
2688 );
2689 const usageLimitsBanner = useMemo(
2690 () =>
2691 usageLimitsReport !== null && usageLimitsPanel !== null
2692 ? // A fresh id per opening: the stack keeps the last dismissed id as "exiting".
2693 usageLimitsBannerItem(
2694 `usage-limits:${usageLimitsPanel.key}:${usageLimitsPanel.now}`,
2695 usageLimitsReport,
2696 environmentId,
2697 () => setUsageLimitsPanel(null),
2698 )
2699 : null,
2700 [environmentId, usageLimitsPanel, usageLimitsReport],
2701 );
2702 // T3 owns /usage-limits only where Limits has data for the selected provider;
2703 // elsewhere the name stays the provider's own and is sent through untouched.
2704 const usageLimitsOffered =
2705 activeProviderStatus !== null &&
2706 hasProviderUsageLimits(activeProviderStatus.driver, providerStatuses, usageLimitSources);
2707 // Answered locally from the last Limits snapshot; the agent never sees it.
2708 const openUsageLimits = useCallback(() => {
2709 const now = Date.now();
2710 const report =
2711 activeProviderInstanceId !== null && usageLimitsKey !== null
2712 ? collectProviderUsageLimits(
2713 activeProviderInstanceId,
2714 providerStatuses,
2715 usageLimitSources,
2716 now,
2717 )
2718 : null;
2719 if (report && usageLimitsKey !== null) {
2720 setUsageLimitsPanel({ key: usageLimitsKey, threadKey: routeThreadKey, now });
2721 return true;
2722 }
2723 setUsageLimitsPanel(null);
2724 toastManager.add({ type: "info", title: "Usage limits are unavailable for this provider" });
2725 return false;
2726 }, [
2727 activeProviderInstanceId,
2728 providerStatuses,
2729 routeThreadKey,
2730 usageLimitSources,
2731 usageLimitsKey,
2732 ]);
2733 // Responses can resolve after navigating away; only the originating thread's panel clears.
2734 const clearUsageLimitsFor = useCallback(
2735 (threadKey: string) =>
2736 setUsageLimitsPanel((current) =>
2737 current !== null && current.threadKey === threadKey ? null : current,
2738 ),
2739 [],
2740 );
26232741 const {
26242742 beginLocalDispatch,
26252743 resetLocalDispatch,
......@@ -5599,8 +5717,11 @@ export default function ChatView(props: ChatViewProps) {
55995717 resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem];
56005718 const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem];
56015719 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
5720 // The user asked for this one, so it leads the notice tier instead of trailing it.
5721 const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner];
56025722 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
56035723 return [
5724 ...usageLimitsItems,
56045725 ...systemComposerBannerItems,
56055726 ...backgroundLivenessItems,
56065727 ...resumeCompactionItems,
......@@ -5609,6 +5730,7 @@ export default function ChatView(props: ChatViewProps) {
56095730 ];
56105731 }
56115732 return [
5733 ...usageLimitsItems,
56125734 ...systemComposerBannerItems,
56135735 ...backgroundLivenessItems,
56145736 ...resumeCompactionItems,
......@@ -5663,6 +5785,7 @@ export default function ChatView(props: ChatViewProps) {
56635785 resumeCompactionBannerItem,
56645786 showBranchMismatchBanner,
56655787 systemComposerBannerItems,
5788 usageLimitsBanner,
56665789 wokeThreadBannerItem,
56675790 ]);
56685791 useEffect(() => {
......@@ -6076,6 +6199,23 @@ export default function ChatView(props: ChatViewProps) {
60766199 },
60776200 ) => {
60786201 e?.preventDefault();
6202 // Typed out in full rather than picked from the menu. Attachments or contexts
6203 // mean the user is sending a prompt, so those go through as usual.
6204 if (
6205 usageLimitsOffered &&
6206 usageLimitsKey !== null &&
6207 !directAnnotation &&
6208 !composerHasNonPromptContent &&
6209 isUsageLimitsCommand(promptRef.current)
6210 ) {
6211 if (openUsageLimits()) {
6212 promptRef.current = "";
6213 setComposerDraftPrompt(composerDraftTarget, "");
6214 composerRef.current?.resetCursorState();
6215 }
6216 return;
6217 }
6218
60796219 const notifyDirectAnnotationAttached = () => {
60806220 if (!directAnnotation) return;
60816221 toastManager.add(
......@@ -6718,6 +6858,10 @@ export default function ChatView(props: ChatViewProps) {
67186858 failure = startResult;
67196859 } else {
67206860 turnStartSucceeded = true;
6861 // The turn is under way and will spend quota, so that thread's limits
6862 // snapshot is stale. Uploads may have outlasted a navigation, so only
6863 // the sending thread's panel clears.
6864 clearUsageLimitsFor(routeThreadKey);
67216865 if (turnUsesAttachmentUploads) {
67226866 releaseDraftAttachments(composerAttachmentsSnapshot);
67236867 }
......@@ -7138,6 +7282,7 @@ export default function ChatView(props: ChatViewProps) {
71387282 }
71397283
71407284 if (failure === null) {
7285 clearUsageLimitsFor(routeThreadKey);
71417286 acknowledgeActiveThreadWoke();
71427287 sendInFlightRef.current = false;
71437288 return;
......@@ -7174,6 +7319,8 @@ export default function ChatView(props: ChatViewProps) {
71747319 startThreadTurn,
71757320 environmentId,
71767321 composerRef,
7322 clearUsageLimitsFor,
7323 routeThreadKey,
71777324 ],
71787325 );
71797326
......@@ -7949,6 +8096,15 @@ export default function ChatView(props: ChatViewProps) {
79498096 }
79508097 isPreparingWorktree={isPreparingWorktree}
79518098 bannerItems={composerBannerItems}
8099 // With attachments or contexts aboard the pick just inserts the
8100 // text, so it sends as a prompt like the typed path would.
8101 onUsageLimitsCommand={
8102 usageLimitsOffered &&
8103 usageLimitsKey !== null &&
8104 !composerHasNonPromptContent
8105 ? openUsageLimits
8106 : undefined
8107 }
79528108 environmentUnavailable={activeEnvironmentUnavailableState}
79538109 activePendingApproval={activePendingApproval}
79548110 pendingApprovals={pendingApprovals}
apps/web/src/components/chat/ChatComposer.tsx+16
......@@ -22,6 +22,7 @@ import {
2222import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
2323import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
2424import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
25import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";
2526import {
2627 Fragment,
2728 memo,
......@@ -1191,6 +1192,8 @@ export interface ChatComposerProps {
11911192 sendDisabledReason: string | null;
11921193 isPreparingWorktree: boolean;
11931194 bannerItems: readonly ComposerBannerStackItem[];
1195 /** Picking /usage-limits from the menu is the action itself; the draft keeps nothing of it. */
1196 onUsageLimitsCommand?: (() => void) | undefined;
11941197 environmentUnavailable: {
11951198 readonly label: string;
11961199 readonly connection: EnvironmentConnectionPresentation;
......@@ -2684,6 +2687,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
26842687 };
26852688 }, [readComposerSnapshot]);
26862689
2690 const { onUsageLimitsCommand } = props;
26872691 const onSelectComposerItem = useCallback(
26882692 (item: ComposerCommandItem) => {
26892693 if (composerSelectLockRef.current) return;
......@@ -2734,6 +2738,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
27342738 return;
27352739 }
27362740 if (item.type === "provider-slash-command") {
2741 if (item.command.name === USAGE_LIMITS_COMMAND.name && onUsageLimitsCommand) {
2742 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
2743 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
2744 focusEditorAfterReplace: false,
2745 });
2746 if (applied) {
2747 setComposerHighlightedItemId(null);
2748 onUsageLimitsCommand();
2749 }
2750 return;
2751 }
27372752 const replacement = `/${item.command.name} `;
27382753 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
27392754 snapshot.value,
......@@ -2774,6 +2789,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
27742789 applyPromptReplacement,
27752790 handleInteractionModeChange,
27762791 planModeUiEnabled,
2792 onUsageLimitsCommand,
27772793 resolveActiveComposerTrigger,
27782794 ],
27792795 );
apps/web/src/components/chat/ComposerUsageLimits.tsx created+97
......@@ -0,0 +1,97 @@
1import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts";
2import { limitsNotice } from "@t3tools/shared/usageLimits";
3import { GaugeIcon } from "lucide-react";
4
5import { getDriverOption } from "../settings/providerDriverMeta";
6import { LimitWindows, ResetCredits } from "../usage/UsageLimits";
7import { ComposerBanner } from "./ComposerBanner";
8import type { ComposerBannerStackItem } from "./ComposerBannerStack";
9
10/** Driver name, then the instance when there could be more than one of that driver. */
11function accountLabel(account: UsageLimitsReport["accounts"][number]): string {
12 if (!account.instanceId) return account.label;
13 const driver = getDriverOption(account.driver)?.label ?? String(account.driver);
14 const instance =
15 account.displayName?.trim() ||
16 (String(account.instanceId) !== String(account.driver) ? account.instanceId : "");
17 // The default instance is often named after its driver; saying it twice adds nothing.
18 return instance && instance.toLowerCase() !== driver.toLowerCase()
19 ? `${driver} · ${instance}`
20 : driver;
21}
22
23/** The /usage-limits result as a composer notice: it stacks under warnings and dismisses like one. */
24export function usageLimitsBannerItem(
25 id: string,
26 report: UsageLimitsReport,
27 environmentId: EnvironmentId,
28 onDismiss: () => void,
29): ComposerBannerStackItem {
30 const [first] = report.accounts;
31 const single = report.accounts.length === 1 && first ? first : null;
32 const summary = single
33 ? [accountLabel(single), single.plan].filter(Boolean).join(" · ")
34 : `${report.accounts.length} accounts`;
35 return {
36 id,
37 variant: "info",
38 priority: "notice",
39 icon: <GaugeIcon />,
40 title: "Usage limits",
41 description: summary,
42 dismissLabel: "Dismiss usage limits",
43 onDismiss,
44 children: <UsageLimitsBannerBody report={report} environmentId={environmentId} />,
45 };
46}
47
48function UsageLimitsBannerBody({
49 report,
50 environmentId,
51}: {
52 readonly report: UsageLimitsReport;
53 readonly environmentId: EnvironmentId;
54}) {
55 const now = Date.parse(report.createdAt);
56 return (
57 <ComposerBanner.Scroll>
58 <ComposerBanner.Body className="flex flex-col gap-2 pt-1 pb-1.5 pe-2">
59 {report.accounts.map((account) => {
60 const notice = limitsNotice(account.limits);
61 return (
62 <div key={account.id} className="flex min-w-0 flex-col gap-1">
63 {report.accounts.length > 1 ? (
64 <span className="truncate text-xs text-muted-foreground">
65 {[accountLabel(account), account.plan].filter(Boolean).join(" · ")}
66 </span>
67 ) : null}
68 {notice ? (
69 <span className="text-xs text-muted-foreground">{notice}</span>
70 ) : (
71 <LimitWindows
72 compact
73 driver={account.driver}
74 windows={account.limits.windows}
75 now={now}
76 />
77 )}
78 {account.instanceId && account.limits.resetCredits ? (
79 <ResetCredits
80 environmentId={environmentId}
81 instanceId={account.instanceId}
82 credits={account.limits.resetCredits}
83 now={now}
84 />
85 ) : null}
86 </div>
87 );
88 })}
89 {report.notices.map((notice) => (
90 <span key={notice} className="text-xs text-muted-foreground">
91 {notice}
92 </span>
93 ))}
94 </ComposerBanner.Body>
95 </ComposerBanner.Scroll>
96 );
97}
apps/web/src/components/usage/UsageLimits.tsx+19-12
......@@ -153,24 +153,31 @@ function WindowBar({
153153 );
154154}
155155
156/** One account's windows as rows: label and percent, bar, pace and countdown. */
157function LimitWindows({
156/**
157 * One account's windows as rows: label and percent, bar, pace and countdown.
158 * Compact rows fit the composer panel with narrower columns.
159 */
160export function LimitWindows({
158161 driver,
159162 windows,
160163 now,
164 compact = false,
161165}: {
162166 readonly driver: ServerProvider["driver"];
163167 readonly windows: ReadonlyArray<ServerProviderUsageWindow>;
164168 readonly now: number;
169 readonly compact?: boolean;
165170}) {
166171 const color = barColor(driver);
167172 return (
168 <div className="grid grid-cols-[11rem_minmax(0,1fr)_7rem] gap-x-4 gap-y-1">
169 {windows.map((window, index) => {
170 // Windows that reset together show the countdown once.
171 const previous = windows[index - 1];
172 const sharesReset =
173 previous?.resetsAt !== undefined && previous.resetsAt === window.resetsAt;
173 <div
174 className={
175 compact
176 ? "grid grid-cols-[minmax(0,9rem)_minmax(3rem,1fr)_auto] gap-x-3 gap-y-0.5"
177 : "grid grid-cols-[11rem_minmax(0,1fr)_7rem] gap-x-4 gap-y-1"
178 }
179 >
180 {windows.map((window) => {
174181 const pace = paceOf(window, now);
175182 const resetsIn = formatResetsIn(window, now);
176183 return (
......@@ -182,9 +189,9 @@ function LimitWindows({
182189 </span>
183190 </span>
184191 <WindowBar color={color} window={window} now={now} />
185 <span className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
192 <span className="flex items-center gap-2 text-xs whitespace-nowrap text-muted-foreground tabular-nums">
186193 {pace ? <PaceIcon pace={pace} /> : null}
187 <span className="ms-auto shrink-0">{sharesReset ? "" : (resetsIn ?? "")}</span>
194 <span className="ms-auto shrink-0">{resetsIn ?? ""}</span>
188195 </span>
189196 </Fragment>
190197 );
......@@ -292,7 +299,7 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {
292299 * Banked reset credits with a confirmed redeem action. Redeeming spends a
293300 * credit the provider granted the user, so it never fires on a bare click.
294301 */
295function ResetCredits({
302export function ResetCredits({
296303 environmentId,
297304 instanceId,
298305 credits,
......@@ -341,7 +348,7 @@ function ResetCredits({
341348 <span className="tabular-nums">{summary}</span>
342349 {credits.availableCount > 0 ? (
343350 <Button size="xs" variant="outline" disabled={busy} onClick={() => setConfirming(true)}>
344 {busy ? "Using credit…" : "Use a reset credit"}
351 {busy ? "Using…" : "Use reset"}
345352 </Button>
346353 ) : null}
347354 {status ? <span className="text-foreground">{status}</span> : null}
apps/web/src/connection/runtime.ts+5-1
......@@ -32,7 +32,11 @@ type ConnectionLayerSource =
3232
3333const providedClientConnectionLayer = snapshotLoaderLayer.pipe(
3434 Layer.provideMerge(
35 Connection.layerWithOptions({ environmentThemes: true, usageLimitSources: true }),
35 Connection.layerWithOptions({
36 environmentThemes: true,
37 usageLimitSources: true,
38 usageLimitsCommand: true,
39 }),
3640 ),
3741 Layer.provideMerge(
3842 Layer.mergeAll(
apps/web/src/state/server.ts+1
......@@ -29,6 +29,7 @@ export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRunt
2929 initialConfigValueAtom: environmentSession.initialConfigValueAtom,
3030 environmentThemes: true,
3131 usageLimitSources: true,
32 usageLimitsCommand: true,
3233});
3334export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({
3435 catalogValueAtom: environmentCatalog.catalogValueAtom,
docs/user/usage.md+5
......@@ -45,6 +45,11 @@ next reset.
4545
4646If a window looks stale, refresh Limits to re-check every provider and hub.
4747
48Pick `/usage-limits` from the composer's command menu, or send it as a message, to check the
49current model's limits without leaving the conversation. The result opens above the composer and
50closes when you dismiss it or send your next message. It uses the same snapshot as **Usage → Limits**, so it does not run the agent or refresh
51anything. The command is offered only for providers that appear under **Usage → Limits**.
52
4853API-key accounts may not report subscription limits. This also applies to Claude connections
4954using a proxy through `ANTHROPIC_AUTH_TOKEN`.
5055
packages/client-runtime/src/rpc/session.ts+3
......@@ -57,6 +57,8 @@ export interface RpcSession {
5757export interface RpcSessionOptions {
5858 readonly environmentThemes?: boolean;
5959 readonly usageLimitSources?: boolean;
60 /** This client answers /usage-limits itself, so the server may advertise it. */
61 readonly usageLimitsCommand?: boolean;
6062}
6163
6264export class RpcSessionFactory extends Context.Service<
......@@ -153,6 +155,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* (
153155 const serverConfigInput: ServerConfigSubscriptionInput = {
154156 ...(options.environmentThemes === true ? { environmentThemes: true } : {}),
155157 ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}),
158 ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}),
156159 };
157160
158161 const connect = Effect.fnUntraced(function* (connection: PreparedConnection) {
packages/client-runtime/src/state/server.ts+4
......@@ -358,6 +358,7 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven
358358export interface ServerConfigSubscriptionOptions {
359359 readonly environmentThemes?: boolean;
360360 readonly usageLimitSources?: boolean;
361 readonly usageLimitsCommand?: boolean;
361362}
362363
363364export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")(
......@@ -425,6 +426,7 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf
425426 yield* subscribe(WS_METHODS.subscribeServerConfig, {
426427 ...(subscription.environmentThemes === true ? { environmentThemes: true } : {}),
427428 ...(subscription.usageLimitSources === true ? { usageLimitSources: true } : {}),
429 ...(subscription.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}),
428430 }).pipe(
429431 Stream.runForEach((event) =>
430432 Effect.gen(function* () {
......@@ -621,6 +623,7 @@ export function createServerEnvironmentAtoms<R, E>(
621623 readonly environmentThemes?: boolean;
622624 /** Whether this surface renders quota from configured usage-limit sources. */
623625 readonly usageLimitSources?: boolean;
626 readonly usageLimitsCommand?: boolean;
624627 },
625628) {
626629 const configScheduler = createAtomCommandScheduler();
......@@ -636,6 +639,7 @@ export function createServerEnvironmentAtoms<R, E>(
636639 serverConfigStateChanges(environmentId, {
637640 ...(options.environmentThemes === true ? { environmentThemes: true } : {}),
638641 ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}),
642 ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}),
639643 }),
640644 )
641645 .pipe(
packages/contracts/src/providerUsageLimits.ts+21
......@@ -121,3 +121,24 @@ export const ProviderConsumeResetCreditResult = Schema.Struct({
121121 outcome: ProviderConsumeResetCreditOutcome,
122122});
123123export type ProviderConsumeResetCreditResult = typeof ProviderConsumeResetCreditResult.Type;
124
125/** A point-in-time view of one provider's limits, built for the /usage-limits panel. */
126export const UsageLimitsReport = Schema.Struct({
127 createdAt: IsoDateTime,
128 accounts: Schema.Array(
129 Schema.Struct({
130 id: TrimmedNonEmptyString,
131 driver: ProviderDriverKind,
132 label: TrimmedNonEmptyString,
133 plan: Schema.optional(TrimmedNonEmptyString),
134 email: Schema.optional(TrimmedNonEmptyString),
135 sourceLabel: Schema.optional(TrimmedNonEmptyString),
136 instanceId: Schema.optional(ProviderInstanceId),
137 displayName: Schema.optional(Schema.String),
138 accentColor: Schema.optional(Schema.String),
139 limits: ServerProviderUsageLimits,
140 }),
141 ),
142 notices: Schema.Array(Schema.String),
143});
144export type UsageLimitsReport = typeof UsageLimitsReport.Type;
packages/contracts/src/rpc.ts+6
......@@ -1132,6 +1132,12 @@ export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerCon
11321132 environmentThemes: Schema.optional(Schema.Boolean),
11331133 /** Whether this client understands `usageLimitSourcesUpdated` events. */
11341134 usageLimitSources: Schema.optional(Schema.Boolean),
1135 /**
1136 * Whether this client answers `/usage-limits` itself. The server injects
1137 * that command into provider catalogs only for such clients; an older
1138 * client would send it to the provider as an ordinary prompt.
1139 */
1140 usageLimitsCommand: Schema.optional(Schema.Boolean),
11351141 }),
11361142 success: ServerConfigStreamEvent,
11371143 error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]),
packages/shared/src/usageLimits.test.ts+197
......@@ -9,6 +9,10 @@ import {
99import { describe, expect, it } from "vite-plus/test";
1010
1111import {
12 isUsageLimitsCommand,
13 collectProviderUsageLimits,
14 sameUsageLimitCommandCoverage,
15 withUsageLimitsCommands,
1216 collectLimitSources,
1317 collectLimitsGroups,
1418 elapsedShare,
......@@ -304,3 +308,196 @@ describe("collectLimitSources", () => {
304308 ]);
305309 });
306310});
311
312describe("/usage-limits", () => {
313 const limits = { checkedAt: "2026-09-03T11:00:00.000Z", windows: [window] };
314 const selected = provider({
315 usageLimits: limits,
316 auth: { status: "authenticated", email: "same@example.com" },
317 });
318 const sources = [
319 {
320 id: UsageLimitSourceId.make("hub"),
321 kind: "cliproxy" as const,
322 label: "Accounts",
323 checkedAt: limits.checkedAt,
324 accounts: [
325 {
326 id: "duplicate",
327 driver: selected.driver,
328 email: "SAME@example.com",
329 usageLimits: limits,
330 },
331 { id: "oss", driver: selected.driver, plan: "Codex OSS", usageLimits: limits },
332 { id: "other-provider", driver: ProviderDriverKind.make("claude"), usageLimits: limits },
333 ],
334 },
335 ];
336
337 it("keeps accounts and custom instances separate, filtering by driver", () => {
338 const report = collectProviderUsageLimits(
339 selected.instanceId,
340 [
341 selected,
342 provider({
343 instanceId: ProviderInstanceId.make("codex-work"),
344 displayName: "Work",
345 usageLimits: { ...limits, resetCredits: { availableCount: 2 } },
346 }),
347 provider({
348 driver: ProviderDriverKind.make("claude"),
349 instanceId: ProviderInstanceId.make("claude"),
350 usageLimits: limits,
351 }),
352 ],
353 sources,
354 now,
355 );
356 expect(report?.createdAt).toBe("2026-09-03T12:00:00.000Z");
357 expect(report?.accounts.map((account) => account.id)).toEqual([
358 "codex",
359 "codex-work",
360 "hub:oss",
361 ]);
362 expect(report?.accounts[0]).toMatchObject({
363 instanceId: selected.instanceId,
364 email: selected.auth.email,
365 });
366 expect(report?.accounts[1]).toMatchObject({
367 displayName: "Work",
368 limits: { resetCredits: { availableCount: 2 } },
369 });
370 expect(report?.accounts[2]).toMatchObject({
371 label: "Accounts · oss",
372 sourceLabel: "CLI Proxy",
373 plan: "Codex OSS",
374 });
375 expect(report?.notices).toEqual([]);
376 });
377
378 it("supports a source-only provider and keeps duplicates when the native probe failed", () => {
379 expect(
380 collectProviderUsageLimits(selected.instanceId, [provider({})], sources, now)?.accounts.map(
381 (account) => account.id,
382 ),
383 ).toEqual(["hub:duplicate", "hub:oss"]);
384 const failed = provider({ usageLimits: { ...limits, unavailable: { reason: "probeFailed" } } });
385 expect(
386 collectProviderUsageLimits(selected.instanceId, [failed], sources, now)?.accounts.map(
387 (account) => account.id,
388 ),
389 ).toEqual(["codex", "hub:duplicate", "hub:oss"]);
390 expect(collectProviderUsageLimits(selected.instanceId, [provider({})], [], now)).toBeNull();
391 expect(
392 collectProviderUsageLimits(
393 selected.instanceId,
394 [provider({ enabled: false, usageLimits: limits })],
395 [],
396 now,
397 ),
398 ).toBeNull();
399 });
400
401 it("surfaces source errors only for sources that carry the selected driver", () => {
402 const failing = { ...sources[0]!, error: "token expired" };
403 expect(
404 collectProviderUsageLimits(selected.instanceId, [selected], [failing], now)?.notices,
405 ).toEqual(["Accounts: token expired"]);
406 const claudeOnly = { ...failing, accounts: failing.accounts.slice(2) };
407 expect(
408 collectProviderUsageLimits(selected.instanceId, [selected], [claudeOnly], now)?.notices,
409 ).toEqual([]);
410 // A read failure clears the accounts, so the error must not depend on a match.
411 const unreadable = { ...failing, accounts: [] };
412 expect(
413 collectProviderUsageLimits(selected.instanceId, [selected], [unreadable], now)?.notices,
414 ).toEqual(["Accounts: token expired"]);
415 // A source-only provider still gets the report, carrying only the error.
416 const sourceOnly = collectProviderUsageLimits(
417 selected.instanceId,
418 [provider({})],
419 [unreadable],
420 now,
421 );
422 expect(sourceOnly?.accounts).toEqual([]);
423 expect(sourceOnly?.notices).toEqual(["Accounts: token expired"]);
424 });
425
426 it("advertises global and workspace commands only for providers present in Limits", () => {
427 const withWorkspace = provider({
428 workspaceSnapshots: [
429 { cwd: "/tmp/project", checkedAt: limits.checkedAt, slashCommands: [], skills: [] },
430 ],
431 });
432 const [supported] = withUsageLimitsCommands([withWorkspace], sources);
433 expect(supported?.slashCommands.map((command) => command.name)).toEqual(["usage-limits"]);
434 expect(
435 supported?.workspaceSnapshots?.[0]?.slashCommands.map((command) => command.name),
436 ).toEqual(["usage-limits"]);
437 expect(withUsageLimitsCommands([withWorkspace], [])[0]?.slashCommands).toEqual([]);
438 // A provider's own command of the same name is left alone without coverage.
439 const ownCommand = provider({
440 slashCommands: [{ name: "usage-limits", description: "Provider's own" }],
441 });
442 expect(withUsageLimitsCommands([ownCommand], [])[0]?.slashCommands).toEqual([
443 { name: "usage-limits", description: "Provider's own" },
444 ]);
445 const unreadable = { ...sources[0]!, accounts: [], error: "token expired" };
446 expect(
447 withUsageLimitsCommands([withWorkspace], [unreadable])[0]?.slashCommands.map(
448 (command) => command.name,
449 ),
450 ).toEqual(["usage-limits"]);
451 expect(
452 withUsageLimitsCommands([selected], [])[0]?.slashCommands.map((command) => command.name),
453 ).toEqual(["usage-limits"]);
454 });
455});
456
457describe("sameUsageLimitCommandCoverage", () => {
458 const codexAccount = {
459 id: "a",
460 driver: ProviderDriverKind.make("codex"),
461 usageLimits: { checkedAt: "2026-09-03T11:00:00.000Z", windows: [] },
462 };
463 const base = {
464 id: UsageLimitSourceId.make("hub"),
465 kind: "cliproxy" as const,
466 label: "Accounts",
467 checkedAt: "2026-09-03T11:00:00.000Z",
468 };
469 it("ignores quota movement but not the drivers offered the command", () => {
470 const withCodex = [{ ...base, accounts: [codexAccount] }];
471 const withCodexLater = [
472 {
473 ...base,
474 accounts: [
475 {
476 ...codexAccount,
477 usageLimits: { ...codexAccount.usageLimits, checkedAt: "2026-09-03T12:00:00.000Z" },
478 },
479 ],
480 },
481 ];
482 expect(sameUsageLimitCommandCoverage(withCodex, withCodexLater)).toBe(true);
483 expect(sameUsageLimitCommandCoverage(withCodex, [{ ...base, accounts: [] }])).toBe(false);
484 });
485 it("treats a failed read as a change in coverage, in both directions", () => {
486 const empty = [{ ...base, accounts: [] }];
487 const failed = [{ ...base, accounts: [], error: "token expired" }];
488 expect(sameUsageLimitCommandCoverage(empty, failed)).toBe(false);
489 expect(sameUsageLimitCommandCoverage(failed, empty)).toBe(false);
490 expect(
491 sameUsageLimitCommandCoverage(failed, [{ ...base, accounts: [], error: "still down" }]),
492 ).toBe(true);
493 });
494});
495
496describe("isUsageLimitsCommand", () => {
497 it("recognizes only the standalone local action", () => {
498 expect(isUsageLimitsCommand(" /USAGE-LIMITS\n")).toBe(true);
499 expect(isUsageLimitsCommand("/usage-limits explain")).toBe(false);
500 expect(isUsageLimitsCommand("Explain /usage-limits")).toBe(false);
501 expect(isUsageLimitsCommand("/usage")).toBe(false);
502 });
503});
packages/shared/src/usageLimits.ts+145-1
......@@ -7,6 +7,9 @@
77 */
88import {
99 type EnvironmentId,
10 type UsageLimitsReport,
11 type ProviderInstanceId,
12 type ServerProviderSlashCommand,
1013 isProviderAvailable,
1114 type ServerProvider,
1215 type ServerProviderUsageLimits,
......@@ -15,6 +18,8 @@ import {
1518 type UsageLimitSourceSnapshots,
1619} from "@t3tools/contracts";
1720
21import * as DateTime from "effect/DateTime";
22
1823const MINUTE = 60_000;
1924const HOUR = 60 * MINUTE;
2025const DAY = 24 * HOUR;
......@@ -144,7 +149,7 @@ function accountKey(driver: ServerProvider["driver"], email: string | undefined)
144149
145150/** The instance's configured name, else the driver's, else its raw kind. */
146151export function providerLimitsLabel(
147 provider: ServerProvider,
152 provider: Pick<ServerProvider, "driver" | "displayName">,
148153 driverLabel: (driver: ServerProvider["driver"]) => string | undefined,
149154): string {
150155 return provider.displayName?.trim() || driverLabel(provider.driver) || String(provider.driver);
......@@ -209,3 +214,142 @@ export function formatResetsIn(window: ServerProviderUsageWindow, now: number):
209214 if (resetsAt === null) return null;
210215 return resetsAt <= now ? "resets now" : `resets in ${formatDuration(resetsAt - now)}`;
211216}
217
218/** Limit commands are served by T3 from the same snapshots as Usage → Limits. */
219export const USAGE_LIMITS_COMMAND = {
220 name: "usage-limits",
221 description: "Show this provider's usage limits",
222} satisfies ServerProviderSlashCommand;
223
224/** Handled by the client without sending a turn; anything with arguments stays an ordinary prompt. */
225export function isUsageLimitsCommand(prompt: string): boolean {
226 return prompt.trim().toLowerCase() === "/usage-limits";
227}
228
229/**
230 * Whether Limits has anything to say about this driver. A source that failed to
231 * read keeps no accounts, so its error counts for every driver rather than
232 * disappearing until the next successful refresh.
233 */
234export function hasProviderUsageLimits(
235 driver: ServerProvider["driver"],
236 providers: readonly ServerProvider[],
237 sources: UsageLimitSourceSnapshots,
238): boolean {
239 return (
240 providersWithLimits(providers).some((provider) => provider.driver === driver) ||
241 sources.some(
242 (source) =>
243 source.accounts.some((account) => account.driver === driver) ||
244 (source.error !== undefined && source.accounts.length === 0),
245 )
246 );
247}
248
249/**
250 * The drivers a set of sources would offer the command to, where a source that
251 * failed to read counts for every driver. Two snapshots with the same coverage
252 * need no catalog republish, however much their quotas moved.
253 */
254export function sameUsageLimitCommandCoverage(
255 previous: UsageLimitSourceSnapshots,
256 next: UsageLimitSourceSnapshots,
257): boolean {
258 const coverage = (sources: UsageLimitSourceSnapshots) =>
259 new Set(
260 sources.flatMap((source) =>
261 source.error !== undefined && source.accounts.length === 0
262 ? ["*"]
263 : source.accounts.map((account) => String(account.driver)),
264 ),
265 );
266 const before = coverage(previous);
267 const after = coverage(next);
268 return before.size === after.size && [...before].every((driver) => after.has(driver));
269}
270
271/** Advertise on workspace catalogs too, which replace the global command list. */
272export function withUsageLimitsCommands(
273 providers: readonly ServerProvider[],
274 sources: UsageLimitSourceSnapshots,
275): ServerProvider[] {
276 return providers.map((provider) => {
277 if (!hasProviderUsageLimits(provider.driver, providers, sources)) return provider;
278 const commands = (items: readonly ServerProviderSlashCommand[]) => [
279 ...items.filter((command) => command.name !== USAGE_LIMITS_COMMAND.name),
280 USAGE_LIMITS_COMMAND,
281 ];
282 return {
283 ...provider,
284 slashCommands: commands(provider.slashCommands),
285 ...(provider.workspaceSnapshots
286 ? {
287 workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({
288 ...snapshot,
289 slashCommands: commands(snapshot.slashCommands),
290 })),
291 }
292 : {}),
293 };
294 });
295}
296
297/** A point-in-time report; never refreshes or guesses which pooled account serves a turn. */
298export function collectProviderUsageLimits(
299 instanceId: ProviderInstanceId,
300 providers: readonly ServerProvider[],
301 sources: UsageLimitSourceSnapshots,
302 now: number,
303): UsageLimitsReport | null {
304 const selected = providers.find((provider) => provider.instanceId === instanceId);
305 if (!selected || !hasProviderUsageLimits(selected.driver, providers, sources)) return null;
306 const native = providersWithLimits(providers).filter(
307 (provider) => provider.driver === selected.driver,
308 );
309 const nativeAccounts = new Set(
310 native.flatMap((provider) => {
311 const key = accountKey(provider.driver, provider.auth.email);
312 return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable
313 ? [key]
314 : [];
315 }),
316 );
317 const accounts: Array<UsageLimitsReport["accounts"][number]> = [];
318 const notices: string[] = [];
319 for (const provider of native) {
320 if (!provider.usageLimits) continue;
321 accounts.push({
322 id: provider.instanceId,
323 driver: provider.driver,
324 label: `${providerLimitsLabel(provider, () => undefined)} [${provider.instanceId}]`,
325 ...(provider.auth.label ? { plan: provider.auth.label } : {}),
326 instanceId: provider.instanceId,
327 ...(provider.displayName ? { displayName: provider.displayName } : {}),
328 ...(provider.accentColor ? { accentColor: provider.accentColor } : {}),
329 ...(provider.auth.email ? { email: provider.auth.email } : {}),
330 limits: provider.usageLimits,
331 });
332 }
333 for (const source of sources) {
334 const matching = source.accounts.filter((account) => account.driver === selected.driver);
335 for (const account of matching) {
336 const key = accountKey(account.driver, account.email);
337 if (key && nativeAccounts.has(key)) continue;
338 accounts.push({
339 id: `${source.id}:${account.id}`,
340 driver: account.driver,
341 label: `${source.label} · ${account.id}`,
342 sourceLabel: "CLI Proxy",
343 ...(account.plan ? { plan: account.plan } : {}),
344 ...(account.email ? { email: account.email } : {}),
345 limits: account.usageLimits,
346 });
347 }
348 // A source that failed to read has no accounts left to match on, so its
349 // error is reported to every provider rather than silently dropped.
350 if (source.error && (matching.length > 0 || source.accounts.length === 0)) {
351 notices.push(`${source.label}: ${source.error}`);
352 }
353 }
354 return { createdAt: DateTime.formatIso(DateTime.makeUnsafe(now)), accounts, notices };
355}