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 =...@@ -31,7 +31,9 @@ type ConnectionLayerSource =
31 | typeof mobileBackgroundActivityReporterLayer;31 | typeof mobileBackgroundActivityReporterLayer;
3232
33const providedClientConnectionLayer = snapshotLoaderLayer.pipe(33const providedClientConnectionLayer = snapshotLoaderLayer.pipe(
34 Layer.provideMerge(Connection.layerWithOptions({ usageLimitSources: true })),34 Layer.provideMerge(
35 Connection.layerWithOptions({ usageLimitSources: true, usageLimitsCommand: true }),
36 ),
35 Layer.provideMerge(37 Layer.provideMerge(
36 Layer.mergeAll(38 Layer.mergeAll(
37 runtimeContextLayer,39 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...@@ -49,6 +49,7 @@ import { VideoPreviewModal, type VideoPreviewSource } from "../../components/Vid
49import { ProviderIcon } from "../../components/ProviderIcon";49import { ProviderIcon } from "../../components/ProviderIcon";
50import { SymbolView } from "../../components/AppSymbol";50import { SymbolView } from "../../components/AppSymbol";
51import { AppText as Text } from "../../components/AppText";51import { AppText as Text } from "../../components/AppText";
52import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits";
52import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer";53import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer";
53import { ShimmeringWorkContent } from "./thread-work-log";54import { ShimmeringWorkContent } from "./thread-work-log";
54import { ComposerCommandPopover } from "./ComposerCommandPopover";55import { ComposerCommandPopover } from "./ComposerCommandPopover";
...@@ -309,6 +310,14 @@ export function NewTaskDraftScreen(props: {...@@ -309,6 +310,14 @@ export function NewTaskDraftScreen(props: {
309 const isComposerInteractionLocked = isIncomingShareTransferPending || flow.submitting;310 const isComposerInteractionLocked = isIncomingShareTransferPending || flow.submitting;
310 // Also guard while a submit is in flight: an Android back press or iOS311 // Also guard while a submit is in flight: an Android back press or iOS
311 // Cancel would otherwise abandon the screen while the task still starts.312 // 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 );
312 const composerMenu = useComposerCommandMenu({321 const composerMenu = useComposerCommandMenu({
313 draftMessage: flow.prompt,322 draftMessage: flow.prompt,
314 ownerKey: flow.draftKey,323 ownerKey: flow.draftKey,
...@@ -320,6 +329,7 @@ export function NewTaskDraftScreen(props: {...@@ -320,6 +329,7 @@ export function NewTaskDraftScreen(props: {
320 selectedProviderStatus: flow.selectedProviderStatus,329 selectedProviderStatus: flow.selectedProviderStatus,
321 hasThread: false,330 hasThread: false,
322 hasCompactableConversation: false,331 hasCompactableConversation: false,
332 offersUsageLimits: offersUsageLimits,
323 enabled: isComposerFocused && !isComposerInteractionLocked,333 enabled: isComposerFocused && !isComposerInteractionLocked,
324 onChangeDraftMessage: flow.setPrompt,334 onChangeDraftMessage: flow.setPrompt,
325 onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined,335 onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined,
...@@ -908,6 +918,20 @@ export function NewTaskDraftScreen(props: {...@@ -908,6 +918,20 @@ export function NewTaskDraftScreen(props: {
908 );918 );
909 return;919 return;
910 }920 }
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 }
911 // A failed-send restore can leave the draft over the cap on purpose (it935 // A failed-send restore can leave the draft over the cap on purpose (it
912 // never drops the user's files); starting anyway would upload everything936 // never drops the user's files); starting anyway would upload everything
913 // and have the server reject the turn.937 // and have the server reject the turn.
apps/mobile/src/features/threads/ThreadComposer.tsx+52-3
...@@ -7,7 +7,13 @@ import type {...@@ -7,7 +7,13 @@ import type {
7 ProviderInteractionMode,7 ProviderInteractionMode,
8 RuntimeMode,8 RuntimeMode,
9 ServerConfig as T3ServerConfig,9 ServerConfig as T3ServerConfig,
10 UsageLimitsReport,
10} from "@t3tools/contracts";11} from "@t3tools/contracts";
12import {
13 collectProviderUsageLimits,
14 hasProviderUsageLimits,
15 isUsageLimitsCommand,
16} from "@t3tools/shared/usageLimits";
11import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";17import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";
12import type { ReactNode } from "react";18import type { ReactNode } from "react";
13import {19import {
...@@ -20,7 +26,7 @@ import {...@@ -20,7 +26,7 @@ import {
20 useState,26 useState,
21 type RefObject,27 type RefObject,
22} from "react";28} from "react";
23import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native";29import { ActivityIndicator, Alert, Platform, Pressable, View, type ViewStyle } from "react-native";
24import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";30import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
25import {31import {
26 composerAttachmentUploadBlockReason,32 composerAttachmentUploadBlockReason,
...@@ -124,6 +130,8 @@ export interface ThreadComposerProps {...@@ -124,6 +130,8 @@ export interface ThreadComposerProps {
124 readonly onRemoveDraftImage: (imageId: string) => void;130 readonly onRemoveDraftImage: (imageId: string) => void;
125 readonly onStopThread: () => void;131 readonly onStopThread: () => void;
126 readonly onSendMessage: () => Promise<MessageId | null>;132 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;
127 readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void;135 readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void;
128 readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void;136 readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void;
129 readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void;137 readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void;
...@@ -336,6 +344,30 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -336,6 +344,30 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
336 );344 );
337 }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]);345 }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]);
338 const composerOwnerKey = scopedThreadKey(props.environmentId, props.selectedThread.id);346 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
340 const composerMenu = useComposerCommandMenu({372 const composerMenu = useComposerCommandMenu({
341 draftMessage: props.draftMessage,373 draftMessage: props.draftMessage,
...@@ -350,6 +382,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -350,6 +382,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
350 selectedProviderStatus?.showInteractionModeToggle === false382 selectedProviderStatus?.showInteractionModeToggle === false
351 ? undefined383 ? undefined
352 : props.onUpdateInteractionMode,384 : 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,
353 });389 });
354 const voiceInput = useVoiceInputController({390 const voiceInput = useVoiceInputController({
355 ownerKey: composerOwnerKey,391 ownerKey: composerOwnerKey,
...@@ -428,9 +464,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -428,9 +464,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
428 }464 }
429 onEditorFocusChange?.(false);465 onEditorFocusChange?.(false);
430 }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]);466 }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]);
431 const { onSendMessage } = props;
432
433 const handleSend = useCallback(async () => {467 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 }
434 if (voiceInput.blocksSubmission) return;478 if (voiceInput.blocksSubmission) return;
435 const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);479 const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
436 if (inFlightThreadIdsRef.current.has(threadKey)) return;480 if (inFlightThreadIdsRef.current.has(threadKey)) return;
...@@ -453,6 +497,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -453,6 +497,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
453 inFlightThreadIdsRef.current.delete(threadKey);497 inFlightThreadIdsRef.current.delete(threadKey);
454 }498 }
455 }, [499 }, [
500 props.draftMessage,
501 props.draftAttachments.length,
502 onChangeDraftMessage,
503 openUsageLimits,
504 usageLimitsOffered,
456 onSendMessage,505 onSendMessage,
457 props.environmentId,506 props.environmentId,
458 props.environmentLabel,507 props.environmentLabel,
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+83
...@@ -19,6 +19,7 @@ import type {...@@ -19,6 +19,7 @@ import type {
19 RuntimeMode,19 RuntimeMode,
20 ServerConfig as T3ServerConfig,20 ServerConfig as T3ServerConfig,
21 ThreadId,21 ThreadId,
22 UsageLimitsReport,
22 UserInputQuestion,23 UserInputQuestion,
23} from "@t3tools/contracts";24} from "@t3tools/contracts";
24import * as Haptics from "expo-haptics";25import * as Haptics from "expo-haptics";
...@@ -57,6 +58,7 @@ import Animated, {...@@ -57,6 +58,7 @@ import Animated, {
57import { useSafeAreaInsets } from "react-native-safe-area-context";58import { useSafeAreaInsets } from "react-native-safe-area-context";
5859
59import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";60import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
61import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits";
60import type { ComposerEditorHandle } from "../../components/ComposerEditor";62import type { ComposerEditorHandle } from "../../components/ComposerEditor";
61import type { StatusTone } from "../../components/StatusPill";63import type { StatusTone } from "../../components/StatusPill";
62import type { DraftComposerAttachment } from "../../lib/composerImages";64import type { DraftComposerAttachment } from "../../lib/composerImages";
...@@ -70,6 +72,7 @@ import type {...@@ -70,6 +72,7 @@ import type {
70 ThreadFeedEntry,72 ThreadFeedEntry,
71} from "../../lib/threadActivity";73} from "../../lib/threadActivity";
72import { PendingApprovalCard } from "./PendingApprovalCard";74import { PendingApprovalCard } from "./PendingApprovalCard";
75import { ComposerUsageLimits } from "./ComposerUsageLimits";
73import { PendingUserInputCard } from "./PendingUserInputCard";76import { PendingUserInputCard } from "./PendingUserInputCard";
74import {77import {
75 FLOATING_WORKING_CONTROL_COVERAGE,78 FLOATING_WORKING_CONTROL_COVERAGE,
...@@ -359,6 +362,68 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -359,6 +362,68 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
359 const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] =362 const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] =
360 useState<ApprovalRequestId | null>(null);363 useState<ApprovalRequestId | null>(null);
361 const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null;364 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 );
362 const userInputCollapsed =427 const userInputCollapsed =
363 activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId;428 activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId;
364 // The card's height RESERVES keyboard space at all times instead of429 // The card's height RESERVES keyboard space at all times instead of
...@@ -623,6 +688,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -623,6 +688,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
623 return messageId;688 return messageId;
624 }689 }
625690
691 // A sent message makes the snapshot stale; a refused send leaves it in place.
692 clearUsageLimitsFor(targetThreadKey);
693
626 setSubmittedMessageId(messageId);694 setSubmittedMessageId(messageId);
627 setAnchorMessageId(695 setAnchorMessageId(
628 resolveThreadFeedSubmissionAnchor({696 resolveThreadFeedSubmissionAnchor({
...@@ -637,6 +705,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -637,6 +705,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
637 return messageId;705 return messageId;
638 }, [706 }, [
639 anchorMessageId,707 anchorMessageId,
708 clearUsageLimitsFor,
640 props.onSendMessage,709 props.onSendMessage,
641 props.selectedThread.latestTurn,710 props.selectedThread.latestTurn,
642 props.selectedThreadQueueCount,711 props.selectedThreadQueueCount,
...@@ -778,6 +847,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -778,6 +847,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
778 onScrollToEnd={handleScrollToEnd}847 onScrollToEnd={handleScrollToEnd}
779 />848 />
780 <View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>849 <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}
781 {props.activePendingApproval || props.activePendingUserInput ? (863 {props.activePendingApproval || props.activePendingUserInput ? (
782 <Animated.View864 <Animated.View
783 className="shrink-0 gap-3 px-4 pb-3"865 className="shrink-0 gap-3 px-4 pb-3"
...@@ -846,6 +928,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -846,6 +928,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
846 onRemoveDraftImage={props.onRemoveDraftImage}928 onRemoveDraftImage={props.onRemoveDraftImage}
847 onStopThread={props.onStopThread}929 onStopThread={props.onStopThread}
848 onSendMessage={handleSendMessage}930 onSendMessage={handleSendMessage}
931 onShowUsageLimits={showUsageLimits}
849 onReconnectEnvironment={props.onReconnectEnvironment}932 onReconnectEnvironment={props.onReconnectEnvironment}
850 onUpdateModelSelection={props.onUpdateThreadModelSelection}933 onUpdateModelSelection={props.onUpdateThreadModelSelection}
851 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}934 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
apps/mobile/src/features/threads/use-composer-command-menu.ts+29
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type { EnvironmentId, ProviderInteractionMode, ServerProvider } from "@t3tools/contracts";1import type { EnvironmentId, ProviderInteractionMode, ServerProvider } from "@t3tools/contracts";
2import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";
2import {3import {
3 detectComposerTrigger,4 detectComposerTrigger,
4 replaceTextRange,5 replaceTextRange,
...@@ -36,6 +37,8 @@ export function buildComposerSlashCommandItems(input: {...@@ -36,6 +37,8 @@ export function buildComposerSlashCommandItems(input: {
36 readonly atMessageStart: boolean;37 readonly atMessageStart: boolean;
37 readonly hasThread: boolean;38 readonly hasThread: boolean;
38 readonly hasCompactableConversation?: boolean;39 readonly hasCompactableConversation?: boolean;
40 /** Whether T3 itself offers /usage-limits for the selected provider. */
41 readonly offersUsageLimits?: boolean;
39 readonly allowInteractionMode: boolean;42 readonly allowInteractionMode: boolean;
40 readonly selectedProviderStatus: Pick<43 readonly selectedProviderStatus: Pick<
41 ServerProvider,44 ServerProvider,
...@@ -78,6 +81,11 @@ export function buildComposerSlashCommandItems(input: {...@@ -78,6 +81,11 @@ export function buildComposerSlashCommandItems(input: {
78 for (const command of input.selectedProviderStatus?.slashCommands ?? []) {81 for (const command of input.selectedProviderStatus?.slashCommands ?? []) {
79 if (!command.name.toLowerCase().includes(query)) continue;82 if (!command.name.toLowerCase().includes(query)) continue;
80 if (command.name === "compact" && !input.hasCompactableConversation) continue;83 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 }
81 if (89 if (
82 !input.hasThread &&90 !input.hasThread &&
83 input.selectedProviderStatus?.driver === "codex" &&91 input.selectedProviderStatus?.driver === "codex" &&
...@@ -143,9 +151,11 @@ export function useComposerCommandMenu({...@@ -143,9 +151,11 @@ export function useComposerCommandMenu({
143 selectedProviderStatus,151 selectedProviderStatus,
144 hasThread,152 hasThread,
145 hasCompactableConversation,153 hasCompactableConversation,
154 offersUsageLimits = false,
146 enabled = true,155 enabled = true,
147 onChangeDraftMessage,156 onChangeDraftMessage,
148 onUpdateInteractionMode,157 onUpdateInteractionMode,
158 onUsageLimits,
149}: {159}: {
150 readonly draftMessage: string;160 readonly draftMessage: string;
151 readonly ownerKey: string | null;161 readonly ownerKey: string | null;
...@@ -154,9 +164,13 @@ export function useComposerCommandMenu({...@@ -154,9 +164,13 @@ export function useComposerCommandMenu({
154 readonly selectedProviderStatus: ServerProvider | null;164 readonly selectedProviderStatus: ServerProvider | null;
155 readonly hasThread: boolean;165 readonly hasThread: boolean;
156 readonly hasCompactableConversation: boolean;166 readonly hasCompactableConversation: boolean;
167 /** Whether T3 itself offers /usage-limits for the selected provider. */
168 readonly offersUsageLimits?: boolean;
157 readonly enabled?: boolean;169 readonly enabled?: boolean;
158 readonly onChangeDraftMessage: (value: string) => void;170 readonly onChangeDraftMessage: (value: string) => void;
159 readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void;171 readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void;
172 /** Picking /usage-limits is the action itself; the draft keeps nothing of it. */
173 readonly onUsageLimits?: () => void;
160}) {174}) {
161 const [selection, setSelection] = useState(() => composerSelectionAtEnd(draftMessage));175 const [selection, setSelection] = useState(() => composerSelectionAtEnd(draftMessage));
162 const previousOwnerKeyRef = useRef(ownerKey);176 const previousOwnerKeyRef = useRef(ownerKey);
...@@ -267,6 +281,7 @@ export function useComposerCommandMenu({...@@ -267,6 +281,7 @@ export function useComposerCommandMenu({
267 atMessageStart: trigger.rangeStart === 0,281 atMessageStart: trigger.rangeStart === 0,
268 hasThread,282 hasThread,
269 hasCompactableConversation,283 hasCompactableConversation,
284 offersUsageLimits,
270 allowInteractionMode: onUpdateInteractionMode !== undefined,285 allowInteractionMode: onUpdateInteractionMode !== undefined,
271 selectedProviderStatus,286 selectedProviderStatus,
272 });287 });
...@@ -390,12 +405,25 @@ export function useComposerCommandMenu({...@@ -390,12 +405,25 @@ export function useComposerCommandMenu({
390 selectedProviderStatus,405 selectedProviderStatus,
391 skills,406 skills,
392 trigger,407 trigger,
408 offersUsageLimits,
393 ]);409 ]);
394410
395 const onSelect = useCallback(411 const onSelect = useCallback(
396 (item: ComposerCommandItem) => {412 (item: ComposerCommandItem) => {
397 if (!trigger) return;413 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
399 const result = resolveComposerCommandSelection({427 const result = resolveComposerCommandSelection({
400 draftMessage,428 draftMessage,
401 trigger,429 trigger,
...@@ -414,6 +442,7 @@ export function useComposerCommandMenu({...@@ -414,6 +442,7 @@ export function useComposerCommandMenu({
414 draftMessage,442 draftMessage,
415 onChangeDraftMessage,443 onChangeDraftMessage,
416 onUpdateInteractionMode,444 onUpdateInteractionMode,
445 onUsageLimits,
417 selectedProviderStatus?.showInteractionModeToggle,446 selectedProviderStatus?.showInteractionModeToggle,
418 trigger,447 trigger,
419 ],448 ],
apps/mobile/src/features/usage/UsageLimitsSection.tsx+32-10
...@@ -69,9 +69,9 @@ function WindowRow(props: {...@@ -69,9 +69,9 @@ function WindowRow(props: {
69 <View69 <View
70 className={70 className={
71 used >= 9071 used >= 90
72 ? "h-full rounded-full bg-destructive"72 ? "h-full rounded-full bg-red-500"
73 : used >= 7073 : used >= 70
74 ? "h-full rounded-full bg-warning"74 ? "h-full rounded-full bg-amber-500"
75 : "h-full rounded-full bg-foreground"75 : "h-full rounded-full bg-foreground"
76 }76 }
77 style={[77 style={[
...@@ -99,7 +99,7 @@ function WindowRow(props: {...@@ -99,7 +99,7 @@ function WindowRow(props: {
99}99}
100100
101/** One account: icon, name and plan on a single line, then its windows. */101/** One account: icon, name and plan on a single line, then its windows. */
102function AccountLimits(props: {102export function AccountLimits(props: {
103 readonly driver: Driver;103 readonly driver: Driver;
104 readonly label: string;104 readonly label: string;
105 readonly instanceLabel: string;105 readonly instanceLabel: string;
...@@ -107,14 +107,23 @@ function AccountLimits(props: {...@@ -107,14 +107,23 @@ function AccountLimits(props: {
107 readonly limits: ServerProvider["usageLimits"];107 readonly limits: ServerProvider["usageLimits"];
108 readonly now: number;108 readonly now: number;
109 readonly first: boolean;109 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;
110 readonly footer?: ReactNode;114 readonly footer?: ReactNode;
111}) {115}) {
112 const { limits, now } = props;116 const { limits, now, dense = false } = props;
113 const color = useBarColor(props.driver);117 const color = useBarColor(props.driver);
114 if (!limits) return null;118 if (!limits) return null;
115 const notice = limitsNotice(limits);119 const notice = limitsNotice(limits);
120 const padding = dense ? "px-4 py-3" : "p-4";
116 return (121 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 >
118 <View className="flex-row items-center gap-2">127 <View className="flex-row items-center gap-2">
119 <ProviderIcon provider={props.driver} size={16} />128 <ProviderIcon provider={props.driver} size={16} />
120 <View className="min-w-0 flex-1 flex-row items-baseline gap-2">129 <View className="min-w-0 flex-1 flex-row items-baseline gap-2">
...@@ -130,6 +139,7 @@ function AccountLimits(props: {...@@ -130,6 +139,7 @@ function AccountLimits(props: {
130 </Text>139 </Text>
131 ) : null}140 ) : null}
132 </View>141 </View>
142 {props.trailing}
133 </View>143 </View>
134 {notice ? (144 {notice ? (
135 <Text className="text-sm text-foreground-muted">{notice}</Text>145 <Text className="text-sm text-foreground-muted">{notice}</Text>
...@@ -157,13 +167,15 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {...@@ -157,13 +167,15 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {
157 * credit the provider granted the user, so it goes through the native167 * credit the provider granted the user, so it goes through the native
158 * confirm alert rather than firing on a bare tap.168 * confirm alert rather than firing on a bare tap.
159 */169 */
160function ResetCredits(props: {170export function ResetCredits(props: {
161 readonly environmentId: EnvironmentId;171 readonly environmentId: EnvironmentId;
162 readonly instanceId: ProviderInstanceId;172 readonly instanceId: ProviderInstanceId;
163 readonly credits: ServerProviderResetCredits;173 readonly credits: ServerProviderResetCredits;
164 readonly now: number;174 readonly now: number;
175 /** A smaller pill for the composer card. */
176 readonly dense?: boolean;
165}) {177}) {
166 const { environmentId, instanceId, credits, now } = props;178 const { environmentId, instanceId, credits, now, dense = false } = props;
167 const consume = useAtomCommand(serverEnvironment.consumeResetCredit, {179 const consume = useAtomCommand(serverEnvironment.consumeResetCredit, {
168 reportFailure: false,180 reportFailure: false,
169 });181 });
...@@ -217,10 +229,20 @@ function ResetCredits(props: {...@@ -217,10 +229,20 @@ function ResetCredits(props: {
217 accessibilityState={{ disabled: busy }}229 accessibilityState={{ disabled: busy }}
218 disabled={busy}230 disabled={busy}
219 onPress={confirm}231 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 }
221 >237 >
222 <Text className="text-sm font-t3-medium text-foreground">238 <Text
223 {busy ? "Using credit…" : "Use a reset credit"}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"}
224 </Text>246 </Text>
225 </Pressable>247 </Pressable>
226 ) : null}248 ) : null}
apps/mobile/src/state/server.ts+1
...@@ -8,6 +8,7 @@ import { environmentSession } from "./session";...@@ -8,6 +8,7 @@ import { environmentSession } from "./session";
8export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, {8export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, {
9 initialConfigValueAtom: environmentSession.initialConfigValueAtom,9 initialConfigValueAtom: environmentSession.initialConfigValueAtom,
10 usageLimitSources: true,10 usageLimitSources: true,
11 usageLimitsCommand: true,
11});12});
12export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({13export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({
13 catalogValueAtom: environmentCatalog.catalogValueAtom,14 catalogValueAtom: environmentCatalog.catalogValueAtom,
apps/server/src/server.test.ts+211-38
...@@ -39,6 +39,7 @@ import {...@@ -39,6 +39,7 @@ import {
39 type ServerLifecycleStreamEvent,39 type ServerLifecycleStreamEvent,
40 ThreadId,40 ThreadId,
41 TurnId,41 TurnId,
42 UsageLimitSourceId,
42 WS_METHODS,43 WS_METHODS,
43 WsRpcGroup,44 WsRpcGroup,
44 EditorId,45 EditorId,
...@@ -498,6 +499,7 @@ const buildAppUnderTest = (options?: {...@@ -498,6 +499,7 @@ const buildAppUnderTest = (options?: {
498 keybindings?: Partial<Keybindings.Keybindings["Service"]>;499 keybindings?: Partial<Keybindings.Keybindings["Service"]>;
499 environmentTheme?: Partial<EnvironmentTheme.EnvironmentThemeService["Service"]>;500 environmentTheme?: Partial<EnvironmentTheme.EnvironmentThemeService["Service"]>;
500 providerRegistry?: Partial<ProviderRegistry.ProviderRegistry["Service"]>;501 providerRegistry?: Partial<ProviderRegistry.ProviderRegistry["Service"]>;
502 usageLimitSources?: Partial<UsageLimitSources.UsageLimitSources["Service"]>;
501 providerService?: Partial<ProviderService.ProviderService["Service"]>;503 providerService?: Partial<ProviderService.ProviderService["Service"]>;
502 providerAuth?: Partial<ProviderAuthService["Service"]>;504 providerAuth?: Partial<ProviderAuthService["Service"]>;
503 providerInstanceRegistry?: Partial<ProviderInstanceRegistry["Service"]>;505 providerInstanceRegistry?: Partial<ProviderInstanceRegistry["Service"]>;
...@@ -756,8 +758,9 @@ const buildAppUnderTest = (options?: {...@@ -756,8 +758,9 @@ const buildAppUnderTest = (options?: {
756 }),758 }),
757 Layer.mock(UsageLimitSources.UsageLimitSources)({759 Layer.mock(UsageLimitSources.UsageLimitSources)({
758 current: Effect.succeed([]),760 current: Effect.succeed([]),
759 streamChanges: Stream.empty,761 streamChanges: Stream.make([]),
760 refresh: Effect.void,762 refresh: Effect.void,
763 ...options?.layers?.usageLimitSources,
761 }),764 }),
762 ),765 ),
763 ),766 ),
...@@ -6246,10 +6249,94 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -6246,10 +6249,94 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
6246 }).pipe(Effect.provide(NodeHttpServer.layerTest)),6249 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
6247 );6250 );
62486251
6249 it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () =>6252 it.effect.each([false, true])(
6250 Effect.gen(function* () {6253 "routes websocket rpc subscribeServerConfig emits provider status updates (limits: %s)",
6251 const nextProviders = [6254 (hasLimits) =>
6252 {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 = {
6253 instanceId: ProviderInstanceId.make("codex"),6340 instanceId: ProviderInstanceId.make("codex"),
6254 driver: ProviderDriverKind.make("codex"),6341 driver: ProviderDriverKind.make("codex"),
6255 enabled: true,6342 enabled: true,
...@@ -6261,43 +6348,129 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -6261,43 +6348,129 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
6261 models: [],6348 models: [],
6262 slashCommands: [],6349 slashCommands: [],
6263 skills: [],6350 skills: [],
6264 },6351 usageLimits: {
6265 ] as const;6352 checkedAt: "2026-04-11T00:00:00.000Z",
62666353 windows: [{ id: "weekly", kind: "weekly" as const, label: "Weekly", usedPercent: 25 }],
6267 yield* buildAppUnderTest({
6268 layers: {
6269 keybindings: {
6270 loadConfigState: Effect.succeed({
6271 keybindings: [],
6272 issues: [],
6273 }),
6274 streamChanges: Stream.empty,
6275 },6354 },
6276 providerRegistry: {6355 };
6277 getProviders: Effect.succeed([]),6356 yield* buildAppUnderTest({
6278 streamChanges: Stream.succeed(nextProviders),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 },
6279 },6366 },
6280 },6367 });
6281 });
62826368
6283 const wsUrl = yield* getWsServerUrl("/ws");6369 const wsUrl = yield* getWsServerUrl("/ws");
6284 const events = yield* Effect.scoped(6370 const events = yield* Effect.scoped(
6285 withWsRpcClient(wsUrl, (client) =>6371 withWsRpcClient(wsUrl, (client) =>
6286 client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect),6372 client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect),
6287 ),6373 ),
6288 );6374 );
62896375
6290 const [first, second] = Array.from(events);6376 const [first, second] = Array.from(events);
6291 assert.equal(first?.type, "snapshot");6377 assert.equal(first?.type, "snapshot");
6292 if (first?.type === "snapshot") {6378 if (first?.type === "snapshot") {
6293 assert.deepEqual(first.config.providers, []);6379 assert.deepEqual(first.config.providers, [codex]);
6294 }6380 }
6295 assert.deepEqual(second, {6381 assert.deepEqual(second, {
6296 version: 1,6382 version: 1,
6297 type: "providerStatuses",6383 type: "providerStatuses",
6298 payload: { providers: nextProviders },6384 payload: { providers: [{ ...codex, version: "1.0.1" }] },
6299 });6385 });
6300 }).pipe(Effect.provide(NodeHttpServer.layerTest)),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)),
6301 );6474 );
63026475
6303 it.effect(6476 it.effect(
apps/server/src/ws.ts+102-62
...@@ -1,3 +1,7 @@...@@ -1,3 +1,7 @@
1import {
2 sameUsageLimitCommandCoverage,
3 withUsageLimitsCommands,
4} from "@t3tools/shared/usageLimits";
1import * as Cause from "effect/Cause";5import * as Cause from "effect/Cause";
2import * as Crypto from "effect/Crypto";6import * as Crypto from "effect/Crypto";
3import * as DateTime from "effect/DateTime";7import * as DateTime from "effect/DateTime";
...@@ -1221,59 +1225,67 @@ const makeWsRpcLayer = (...@@ -1221,59 +1225,67 @@ const makeWsRpcLayer = (
1221 );1225 );
1222 };1226 };
12231227
1224 const loadServerConfig = Effect.gen(function* () {1228 // Only clients that answer /usage-limits themselves see it in the catalogs;
1225 const keybindingsConfig = yield* keybindings.loadConfigState;1229 // an older client would send the injected command to the provider.
1226 const providers = yield* providerRegistry.getProviders;1230 const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) =>
1227 const settings = ServerSettings.redactServerSettingsForClient(1231 Effect.gen(function* () {
1228 yield* serverSettings.getSettings,1232 const keybindingsConfig = yield* keybindings.loadConfigState;
1229 );1233 const currentProviders = yield* providerRegistry.getProviders;
1230 const environment = yield* serverEnvironment.getDescriptor;1234 const providers = options.usageLimitsCommand
1231 const auth = yield* serverAuth.getDescriptor();1235 ? withUsageLimitsCommands(currentProviders, yield* usageLimitSources.current)
1232 const availableEditors: ReadonlyArray<EditorId> = yield* resolveAvailableEditorsForConfig(1236 : currentProviders;
1233 externalLauncher.resolveAvailableEditors(),1237 const settings = ServerSettings.redactServerSettingsForClient(
1234 );1238 yield* serverSettings.getSettings,
1235 const fileManagerRevealKind = availableEditors.includes("file-manager")1239 );
1236 ? yield* resolveFileManagerRevealKindForConfig(1240 const environment = yield* serverEnvironment.getDescriptor;
1237 externalLauncher.resolveFileManagerRevealKind(),1241 const auth = yield* serverAuth.getDescriptor();
1238 )1242 const availableEditors: ReadonlyArray<EditorId> = yield* resolveAvailableEditorsForConfig(
1239 : undefined;1243 externalLauncher.resolveAvailableEditors(),
12401244 );
1241 return {1245 const fileManagerRevealKind = availableEditors.includes("file-manager")
1242 environment,1246 ? yield* resolveFileManagerRevealKindForConfig(
1243 auth,1247 externalLauncher.resolveFileManagerRevealKind(),
1244 cwd: config.cwd,1248 )
1245 keybindingsConfigPath: config.keybindingsConfigPath,1249 : undefined;
1246 keybindings: keybindingsConfig.keybindings,1250
1247 issues: keybindingsConfig.issues,1251 return {
1248 providers,1252 environment,
1249 availableEditors,1253 auth,
1250 // Same discovery-with-timeout treatment as editors: a slow probe1254 cwd: config.cwd,
1251 // must not stall server.getConfig, so it degrades to no targets.1255 keybindingsConfigPath: config.keybindingsConfigPath,
1252 remoteOpenTargets: yield* resolveAvailableEditorsForConfig(1256 keybindings: keybindingsConfig.keybindings,
1253 remoteOpenTargets.resolveTargets(),1257 issues: keybindingsConfig.issues,
1254 ),1258 providers,
1255 observability: {1259 availableEditors,
1256 logsDirectoryPath: config.logsDir,1260 // Same discovery-with-timeout treatment as editors: a slow probe
1257 localTracingEnabled: true,1261 // must not stall server.getConfig, so it degrades to no targets.
1258 ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}),1262 remoteOpenTargets: yield* resolveAvailableEditorsForConfig(
1259 otlpTracesEnabled: config.otlpTracesUrl !== undefined,1263 remoteOpenTargets.resolveTargets(),
1260 ...(config.otlpMetricsUrl !== undefined1264 ),
1261 ? { otlpMetricsUrl: config.otlpMetricsUrl }1265 observability: {
1262 : {}),1266 logsDirectoryPath: config.logsDir,
1263 otlpMetricsEnabled: config.otlpMetricsUrl !== undefined,1267 localTracingEnabled: true,
1264 },1268 ...(config.otlpTracesUrl !== undefined
1265 settings,1269 ? { otlpTracesUrl: config.otlpTracesUrl }
1266 shellResumeCompletionMarker: true,1270 : {}),
1267 ...(fileManagerRevealKind === undefined1271 otlpTracesEnabled: config.otlpTracesUrl !== undefined,
1268 ? {}1272 ...(config.otlpMetricsUrl !== undefined
1269 : {1273 ? { otlpMetricsUrl: config.otlpMetricsUrl }
1270 shellRevealInFileManager: true,1274 : {}),
1271 shellRevealInFileManagerKind: fileManagerRevealKind,1275 otlpMetricsEnabled: config.otlpMetricsUrl !== undefined,
1272 }),1276 },
1273 threadResumeCompletionMarker: true,1277 settings,
1274 threadSnapshotPagination: true,1278 shellResumeCompletionMarker: true,
1275 };1279 ...(fileManagerRevealKind === undefined
1276 });1280 ? {}
1281 : {
1282 shellRevealInFileManager: true,
1283 shellRevealInFileManagerKind: fileManagerRevealKind,
1284 }),
1285 threadResumeCompletionMarker: true,
1286 threadSnapshotPagination: true,
1287 };
1288 });
12771289
1278 const refreshGitStatus = (cwd: string) =>1290 const refreshGitStatus = (cwd: string) =>
1279 vcsStatusBroadcaster1291 vcsStatusBroadcaster
...@@ -1750,9 +1762,13 @@ const makeWsRpcLayer = (...@@ -1750,9 +1762,13 @@ const makeWsRpcLayer = (
1750 "rpc.aggregate": "server",1762 "rpc.aggregate": "server",
1751 }),1763 }),
1752 [WS_METHODS.serverGetConfig]: (_input) =>1764 [WS_METHODS.serverGetConfig]: (_input) =>
1753 observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, {1765 observeRpcEffect(
1754 "rpc.aggregate": "server",1766 WS_METHODS.serverGetConfig,
1755 }),1767 loadServerConfig({ usageLimitsCommand: false }),
1768 {
1769 "rpc.aggregate": "server",
1770 },
1771 ),
1756 [WS_METHODS.serverRefreshProviders]: (input) =>1772 [WS_METHODS.serverRefreshProviders]: (input) =>
1757 observeRpcEffect(1773 observeRpcEffect(
1758 WS_METHODS.serverRefreshProviders,1774 WS_METHODS.serverRefreshProviders,
...@@ -2692,6 +2708,8 @@ const makeWsRpcLayer = (...@@ -2692,6 +2708,8 @@ const makeWsRpcLayer = (
2692 observeRpcStreamEffect(2708 observeRpcStreamEffect(
2693 WS_METHODS.subscribeServerConfig,2709 WS_METHODS.subscribeServerConfig,
2694 Effect.gen(function* () {2710 Effect.gen(function* () {
2711 const usageLimitsCommand = input.usageLimitsCommand === true;
2712 const config = yield* loadServerConfig({ usageLimitsCommand });
2695 const keybindingsUpdates = keybindings.streamChanges.pipe(2713 const keybindingsUpdates = keybindings.streamChanges.pipe(
2696 Stream.map((event) => ({2714 Stream.map((event) => ({
2697 version: 1 as const,2715 version: 1 as const,
...@@ -2702,7 +2720,33 @@ const makeWsRpcLayer = (...@@ -2702,7 +2720,33 @@ const makeWsRpcLayer = (
2702 },2720 },
2703 })),2721 })),
2704 );2722 );
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),
2706 Stream.map((providers) => ({2750 Stream.map((providers) => ({
2707 version: 1 as const,2751 version: 1 as const,
2708 type: "providerStatuses" as const,2752 type: "providerStatuses" as const,
...@@ -2763,11 +2807,7 @@ const makeWsRpcLayer = (...@@ -2763,11 +2807,7 @@ const makeWsRpcLayer = (
2763 );2807 );
27642808
2765 return Stream.concat(2809 return Stream.concat(
2766 Stream.make({2810 Stream.make({ version: 1 as const, type: "snapshot" as const, config }),
2767 version: 1 as const,
2768 type: "snapshot" as const,
2769 config: yield* loadServerConfig,
2770 }),
2771 liveUpdates,2811 liveUpdates,
2772 );2812 );
2773 }),2813 }),
apps/web/src/components/ChatView.tsx+156
...@@ -1,3 +1,10 @@...@@ -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";
1import {8import {
2 type AssistantCitation,9 type AssistantCitation,
3 type ApprovalRequestId,10 type ApprovalRequestId,
...@@ -451,6 +458,7 @@ import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "./chat/composerPromptHistory";...@@ -451,6 +458,7 @@ import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "./chat/composerPromptHistory";
451458
452const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];459const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
453const EMPTY_PROVIDERS: ServerProvider[] = [];460const EMPTY_PROVIDERS: ServerProvider[] = [];
461const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = [];
454const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];462const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
455const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};463const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
456function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {464function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {
...@@ -1505,6 +1513,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -1505,6 +1513,11 @@ export default function ChatView(props: ChatViewProps) {
1505 const draft = store.getComposerDraft(composerDraftTarget);1513 const draft = store.getComposerDraft(composerDraftTarget);
1506 return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0;1514 return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0;
1507 });1515 });
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 });
1508 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);1521 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
1509 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);1522 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
1510 const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles);1523 const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles);
...@@ -2620,6 +2633,111 @@ export default function ChatView(props: ChatViewProps) {...@@ -2620,6 +2633,111 @@ export default function ChatView(props: ChatViewProps) {
2620 hasComposerAttachments: composerHasAttachments,2633 hasComposerAttachments: composerHasAttachments,
2621 });2634 });
2622 const activePendingApproval = pendingApprovals[0] ?? null;2635 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 );
2623 const {2741 const {
2624 beginLocalDispatch,2742 beginLocalDispatch,
2625 resetLocalDispatch,2743 resetLocalDispatch,
...@@ -5599,8 +5717,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -5599,8 +5717,11 @@ export default function ChatView(props: ChatViewProps) {
5599 resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem];5717 resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem];
5600 const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem];5718 const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem];
5601 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];5719 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];
5602 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {5722 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
5603 return [5723 return [
5724 ...usageLimitsItems,
5604 ...systemComposerBannerItems,5725 ...systemComposerBannerItems,
5605 ...backgroundLivenessItems,5726 ...backgroundLivenessItems,
5606 ...resumeCompactionItems,5727 ...resumeCompactionItems,
...@@ -5609,6 +5730,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -5609,6 +5730,7 @@ export default function ChatView(props: ChatViewProps) {
5609 ];5730 ];
5610 }5731 }
5611 return [5732 return [
5733 ...usageLimitsItems,
5612 ...systemComposerBannerItems,5734 ...systemComposerBannerItems,
5613 ...backgroundLivenessItems,5735 ...backgroundLivenessItems,
5614 ...resumeCompactionItems,5736 ...resumeCompactionItems,
...@@ -5663,6 +5785,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -5663,6 +5785,7 @@ export default function ChatView(props: ChatViewProps) {
5663 resumeCompactionBannerItem,5785 resumeCompactionBannerItem,
5664 showBranchMismatchBanner,5786 showBranchMismatchBanner,
5665 systemComposerBannerItems,5787 systemComposerBannerItems,
5788 usageLimitsBanner,
5666 wokeThreadBannerItem,5789 wokeThreadBannerItem,
5667 ]);5790 ]);
5668 useEffect(() => {5791 useEffect(() => {
...@@ -6076,6 +6199,23 @@ export default function ChatView(props: ChatViewProps) {...@@ -6076,6 +6199,23 @@ export default function ChatView(props: ChatViewProps) {
6076 },6199 },
6077 ) => {6200 ) => {
6078 e?.preventDefault();6201 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
6079 const notifyDirectAnnotationAttached = () => {6219 const notifyDirectAnnotationAttached = () => {
6080 if (!directAnnotation) return;6220 if (!directAnnotation) return;
6081 toastManager.add(6221 toastManager.add(
...@@ -6718,6 +6858,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -6718,6 +6858,10 @@ export default function ChatView(props: ChatViewProps) {
6718 failure = startResult;6858 failure = startResult;
6719 } else {6859 } else {
6720 turnStartSucceeded = true;6860 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);
6721 if (turnUsesAttachmentUploads) {6865 if (turnUsesAttachmentUploads) {
6722 releaseDraftAttachments(composerAttachmentsSnapshot);6866 releaseDraftAttachments(composerAttachmentsSnapshot);
6723 }6867 }
...@@ -7138,6 +7282,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7138,6 +7282,7 @@ export default function ChatView(props: ChatViewProps) {
7138 }7282 }
71397283
7140 if (failure === null) {7284 if (failure === null) {
7285 clearUsageLimitsFor(routeThreadKey);
7141 acknowledgeActiveThreadWoke();7286 acknowledgeActiveThreadWoke();
7142 sendInFlightRef.current = false;7287 sendInFlightRef.current = false;
7143 return;7288 return;
...@@ -7174,6 +7319,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -7174,6 +7319,8 @@ export default function ChatView(props: ChatViewProps) {
7174 startThreadTurn,7319 startThreadTurn,
7175 environmentId,7320 environmentId,
7176 composerRef,7321 composerRef,
7322 clearUsageLimitsFor,
7323 routeThreadKey,
7177 ],7324 ],
7178 );7325 );
71797326
...@@ -7949,6 +8096,15 @@ export default function ChatView(props: ChatViewProps) {...@@ -7949,6 +8096,15 @@ export default function ChatView(props: ChatViewProps) {
7949 }8096 }
7950 isPreparingWorktree={isPreparingWorktree}8097 isPreparingWorktree={isPreparingWorktree}
7951 bannerItems={composerBannerItems}8098 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 }
7952 environmentUnavailable={activeEnvironmentUnavailableState}8108 environmentUnavailable={activeEnvironmentUnavailableState}
7953 activePendingApproval={activePendingApproval}8109 activePendingApproval={activePendingApproval}
7954 pendingApprovals={pendingApprovals}8110 pendingApprovals={pendingApprovals}
apps/web/src/components/chat/ChatComposer.tsx+16
...@@ -22,6 +22,7 @@ import {...@@ -22,6 +22,7 @@ import {
22import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";22import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
23import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";23import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
24import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";24import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
25import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";
25import {26import {
26 Fragment,27 Fragment,
27 memo,28 memo,
...@@ -1191,6 +1192,8 @@ export interface ChatComposerProps {...@@ -1191,6 +1192,8 @@ export interface ChatComposerProps {
1191 sendDisabledReason: string | null;1192 sendDisabledReason: string | null;
1192 isPreparingWorktree: boolean;1193 isPreparingWorktree: boolean;
1193 bannerItems: readonly ComposerBannerStackItem[];1194 bannerItems: readonly ComposerBannerStackItem[];
1195 /** Picking /usage-limits from the menu is the action itself; the draft keeps nothing of it. */
1196 onUsageLimitsCommand?: (() => void) | undefined;
1194 environmentUnavailable: {1197 environmentUnavailable: {
1195 readonly label: string;1198 readonly label: string;
1196 readonly connection: EnvironmentConnectionPresentation;1199 readonly connection: EnvironmentConnectionPresentation;
...@@ -2684,6 +2687,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2684,6 +2687,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2684 };2687 };
2685 }, [readComposerSnapshot]);2688 }, [readComposerSnapshot]);
26862689
2690 const { onUsageLimitsCommand } = props;
2687 const onSelectComposerItem = useCallback(2691 const onSelectComposerItem = useCallback(
2688 (item: ComposerCommandItem) => {2692 (item: ComposerCommandItem) => {
2689 if (composerSelectLockRef.current) return;2693 if (composerSelectLockRef.current) return;
...@@ -2734,6 +2738,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2734,6 +2738,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2734 return;2738 return;
2735 }2739 }
2736 if (item.type === "provider-slash-command") {2740 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 }
2737 const replacement = `/${item.command.name} `;2752 const replacement = `/${item.command.name} `;
2738 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(2753 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
2739 snapshot.value,2754 snapshot.value,
...@@ -2774,6 +2789,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2774,6 +2789,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2774 applyPromptReplacement,2789 applyPromptReplacement,
2775 handleInteractionModeChange,2790 handleInteractionModeChange,
2776 planModeUiEnabled,2791 planModeUiEnabled,
2792 onUsageLimitsCommand,
2777 resolveActiveComposerTrigger,2793 resolveActiveComposerTrigger,
2778 ],2794 ],
2779 );2795 );
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({...@@ -153,24 +153,31 @@ function WindowBar({
153 );153 );
154}154}
155155
156/** One account's windows as rows: label and percent, bar, pace and countdown. */156/**
157function LimitWindows({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({
158 driver,161 driver,
159 windows,162 windows,
160 now,163 now,
164 compact = false,
161}: {165}: {
162 readonly driver: ServerProvider["driver"];166 readonly driver: ServerProvider["driver"];
163 readonly windows: ReadonlyArray<ServerProviderUsageWindow>;167 readonly windows: ReadonlyArray<ServerProviderUsageWindow>;
164 readonly now: number;168 readonly now: number;
169 readonly compact?: boolean;
165}) {170}) {
166 const color = barColor(driver);171 const color = barColor(driver);
167 return (172 return (
168 <div className="grid grid-cols-[11rem_minmax(0,1fr)_7rem] gap-x-4 gap-y-1">173 <div
169 {windows.map((window, index) => {174 className={
170 // Windows that reset together show the countdown once.175 compact
171 const previous = windows[index - 1];176 ? "grid grid-cols-[minmax(0,9rem)_minmax(3rem,1fr)_auto] gap-x-3 gap-y-0.5"
172 const sharesReset =177 : "grid grid-cols-[11rem_minmax(0,1fr)_7rem] gap-x-4 gap-y-1"
173 previous?.resetsAt !== undefined && previous.resetsAt === window.resetsAt;178 }
179 >
180 {windows.map((window) => {
174 const pace = paceOf(window, now);181 const pace = paceOf(window, now);
175 const resetsIn = formatResetsIn(window, now);182 const resetsIn = formatResetsIn(window, now);
176 return (183 return (
...@@ -182,9 +189,9 @@ function LimitWindows({...@@ -182,9 +189,9 @@ function LimitWindows({
182 </span>189 </span>
183 </span>190 </span>
184 <WindowBar color={color} window={window} now={now} />191 <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">
186 {pace ? <PaceIcon pace={pace} /> : null}193 {pace ? <PaceIcon pace={pace} /> : null}
187 <span className="ms-auto shrink-0">{sharesReset ? "" : (resetsIn ?? "")}</span>194 <span className="ms-auto shrink-0">{resetsIn ?? ""}</span>
188 </span>195 </span>
189 </Fragment>196 </Fragment>
190 );197 );
...@@ -292,7 +299,7 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {...@@ -292,7 +299,7 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {
292 * Banked reset credits with a confirmed redeem action. Redeeming spends a299 * Banked reset credits with a confirmed redeem action. Redeeming spends a
293 * credit the provider granted the user, so it never fires on a bare click.300 * credit the provider granted the user, so it never fires on a bare click.
294 */301 */
295function ResetCredits({302export function ResetCredits({
296 environmentId,303 environmentId,
297 instanceId,304 instanceId,
298 credits,305 credits,
...@@ -341,7 +348,7 @@ function ResetCredits({...@@ -341,7 +348,7 @@ function ResetCredits({
341 <span className="tabular-nums">{summary}</span>348 <span className="tabular-nums">{summary}</span>
342 {credits.availableCount > 0 ? (349 {credits.availableCount > 0 ? (
343 <Button size="xs" variant="outline" disabled={busy} onClick={() => setConfirming(true)}>350 <Button size="xs" variant="outline" disabled={busy} onClick={() => setConfirming(true)}>
344 {busy ? "Using credit…" : "Use a reset credit"}351 {busy ? "Using…" : "Use reset"}
345 </Button>352 </Button>
346 ) : null}353 ) : null}
347 {status ? <span className="text-foreground">{status}</span> : null}354 {status ? <span className="text-foreground">{status}</span> : null}
apps/web/src/connection/runtime.ts+5-1
...@@ -32,7 +32,11 @@ type ConnectionLayerSource =...@@ -32,7 +32,11 @@ type ConnectionLayerSource =
3232
33const providedClientConnectionLayer = snapshotLoaderLayer.pipe(33const providedClientConnectionLayer = snapshotLoaderLayer.pipe(
34 Layer.provideMerge(34 Layer.provideMerge(
35 Connection.layerWithOptions({ environmentThemes: true, usageLimitSources: true }),35 Connection.layerWithOptions({
36 environmentThemes: true,
37 usageLimitSources: true,
38 usageLimitsCommand: true,
39 }),
36 ),40 ),
37 Layer.provideMerge(41 Layer.provideMerge(
38 Layer.mergeAll(42 Layer.mergeAll(
apps/web/src/state/server.ts+1
...@@ -29,6 +29,7 @@ export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRunt...@@ -29,6 +29,7 @@ export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRunt
29 initialConfigValueAtom: environmentSession.initialConfigValueAtom,29 initialConfigValueAtom: environmentSession.initialConfigValueAtom,
30 environmentThemes: true,30 environmentThemes: true,
31 usageLimitSources: true,31 usageLimitSources: true,
32 usageLimitsCommand: true,
32});33});
33export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({34export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({
34 catalogValueAtom: environmentCatalog.catalogValueAtom,35 catalogValueAtom: environmentCatalog.catalogValueAtom,
docs/user/usage.md+5
...@@ -45,6 +45,11 @@ next reset....@@ -45,6 +45,11 @@ next reset.
4545
46If a window looks stale, refresh Limits to re-check every provider and hub.46If 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
48API-key accounts may not report subscription limits. This also applies to Claude connections53API-key accounts may not report subscription limits. This also applies to Claude connections
49using a proxy through `ANTHROPIC_AUTH_TOKEN`.54using a proxy through `ANTHROPIC_AUTH_TOKEN`.
5055
packages/client-runtime/src/rpc/session.ts+3
...@@ -57,6 +57,8 @@ export interface RpcSession {...@@ -57,6 +57,8 @@ export interface RpcSession {
57export interface RpcSessionOptions {57export interface RpcSessionOptions {
58 readonly environmentThemes?: boolean;58 readonly environmentThemes?: boolean;
59 readonly usageLimitSources?: boolean;59 readonly usageLimitSources?: boolean;
60 /** This client answers /usage-limits itself, so the server may advertise it. */
61 readonly usageLimitsCommand?: boolean;
60}62}
6163
62export class RpcSessionFactory extends Context.Service<64export class RpcSessionFactory extends Context.Service<
...@@ -153,6 +155,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* (...@@ -153,6 +155,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* (
153 const serverConfigInput: ServerConfigSubscriptionInput = {155 const serverConfigInput: ServerConfigSubscriptionInput = {
154 ...(options.environmentThemes === true ? { environmentThemes: true } : {}),156 ...(options.environmentThemes === true ? { environmentThemes: true } : {}),
155 ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}),157 ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}),
158 ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}),
156 };159 };
157160
158 const connect = Effect.fnUntraced(function* (connection: PreparedConnection) {161 const connect = Effect.fnUntraced(function* (connection: PreparedConnection) {
packages/client-runtime/src/state/server.ts+4
...@@ -358,6 +358,7 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven...@@ -358,6 +358,7 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven
358export interface ServerConfigSubscriptionOptions {358export interface ServerConfigSubscriptionOptions {
359 readonly environmentThemes?: boolean;359 readonly environmentThemes?: boolean;
360 readonly usageLimitSources?: boolean;360 readonly usageLimitSources?: boolean;
361 readonly usageLimitsCommand?: boolean;
361}362}
362363
363export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")(364export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")(
...@@ -425,6 +426,7 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf...@@ -425,6 +426,7 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf
425 yield* subscribe(WS_METHODS.subscribeServerConfig, {426 yield* subscribe(WS_METHODS.subscribeServerConfig, {
426 ...(subscription.environmentThemes === true ? { environmentThemes: true } : {}),427 ...(subscription.environmentThemes === true ? { environmentThemes: true } : {}),
427 ...(subscription.usageLimitSources === true ? { usageLimitSources: true } : {}),428 ...(subscription.usageLimitSources === true ? { usageLimitSources: true } : {}),
429 ...(subscription.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}),
428 }).pipe(430 }).pipe(
429 Stream.runForEach((event) =>431 Stream.runForEach((event) =>
430 Effect.gen(function* () {432 Effect.gen(function* () {
...@@ -621,6 +623,7 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -621,6 +623,7 @@ export function createServerEnvironmentAtoms<R, E>(
621 readonly environmentThemes?: boolean;623 readonly environmentThemes?: boolean;
622 /** Whether this surface renders quota from configured usage-limit sources. */624 /** Whether this surface renders quota from configured usage-limit sources. */
623 readonly usageLimitSources?: boolean;625 readonly usageLimitSources?: boolean;
626 readonly usageLimitsCommand?: boolean;
624 },627 },
625) {628) {
626 const configScheduler = createAtomCommandScheduler();629 const configScheduler = createAtomCommandScheduler();
...@@ -636,6 +639,7 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -636,6 +639,7 @@ export function createServerEnvironmentAtoms<R, E>(
636 serverConfigStateChanges(environmentId, {639 serverConfigStateChanges(environmentId, {
637 ...(options.environmentThemes === true ? { environmentThemes: true } : {}),640 ...(options.environmentThemes === true ? { environmentThemes: true } : {}),
638 ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}),641 ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}),
642 ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}),
639 }),643 }),
640 )644 )
641 .pipe(645 .pipe(
packages/contracts/src/providerUsageLimits.ts+21
...@@ -121,3 +121,24 @@ export const ProviderConsumeResetCreditResult = Schema.Struct({...@@ -121,3 +121,24 @@ export const ProviderConsumeResetCreditResult = Schema.Struct({
121 outcome: ProviderConsumeResetCreditOutcome,121 outcome: ProviderConsumeResetCreditOutcome,
122});122});
123export type ProviderConsumeResetCreditResult = typeof ProviderConsumeResetCreditResult.Type;123export 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...@@ -1132,6 +1132,12 @@ export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerCon
1132 environmentThemes: Schema.optional(Schema.Boolean),1132 environmentThemes: Schema.optional(Schema.Boolean),
1133 /** Whether this client understands `usageLimitSourcesUpdated` events. */1133 /** Whether this client understands `usageLimitSourcesUpdated` events. */
1134 usageLimitSources: Schema.optional(Schema.Boolean),1134 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),
1135 }),1141 }),
1136 success: ServerConfigStreamEvent,1142 success: ServerConfigStreamEvent,
1137 error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]),1143 error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]),
packages/shared/src/usageLimits.test.ts+197
...@@ -9,6 +9,10 @@ import {...@@ -9,6 +9,10 @@ import {
9import { describe, expect, it } from "vite-plus/test";9import { describe, expect, it } from "vite-plus/test";
1010
11import {11import {
12 isUsageLimitsCommand,
13 collectProviderUsageLimits,
14 sameUsageLimitCommandCoverage,
15 withUsageLimitsCommands,
12 collectLimitSources,16 collectLimitSources,
13 collectLimitsGroups,17 collectLimitsGroups,
14 elapsedShare,18 elapsedShare,
...@@ -304,3 +308,196 @@ describe("collectLimitSources", () => {...@@ -304,3 +308,196 @@ describe("collectLimitSources", () => {
304 ]);308 ]);
305 });309 });
306});310});
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 @@...@@ -7,6 +7,9 @@
7 */7 */
8import {8import {
9 type EnvironmentId,9 type EnvironmentId,
10 type UsageLimitsReport,
11 type ProviderInstanceId,
12 type ServerProviderSlashCommand,
10 isProviderAvailable,13 isProviderAvailable,
11 type ServerProvider,14 type ServerProvider,
12 type ServerProviderUsageLimits,15 type ServerProviderUsageLimits,
...@@ -15,6 +18,8 @@ import {...@@ -15,6 +18,8 @@ import {
15 type UsageLimitSourceSnapshots,18 type UsageLimitSourceSnapshots,
16} from "@t3tools/contracts";19} from "@t3tools/contracts";
1720
21import * as DateTime from "effect/DateTime";
22
18const MINUTE = 60_000;23const MINUTE = 60_000;
19const HOUR = 60 * MINUTE;24const HOUR = 60 * MINUTE;
20const DAY = 24 * HOUR;25const DAY = 24 * HOUR;
...@@ -144,7 +149,7 @@ function accountKey(driver: ServerProvider["driver"], email: string | undefined)...@@ -144,7 +149,7 @@ function accountKey(driver: ServerProvider["driver"], email: string | undefined)
144149
145/** The instance's configured name, else the driver's, else its raw kind. */150/** The instance's configured name, else the driver's, else its raw kind. */
146export function providerLimitsLabel(151export function providerLimitsLabel(
147 provider: ServerProvider,152 provider: Pick<ServerProvider, "driver" | "displayName">,
148 driverLabel: (driver: ServerProvider["driver"]) => string | undefined,153 driverLabel: (driver: ServerProvider["driver"]) => string | undefined,
149): string {154): string {
150 return provider.displayName?.trim() || driverLabel(provider.driver) || String(provider.driver);155 return provider.displayName?.trim() || driverLabel(provider.driver) || String(provider.driver);
...@@ -209,3 +214,142 @@ export function formatResetsIn(window: ServerProviderUsageWindow, now: number):...@@ -209,3 +214,142 @@ export function formatResetsIn(window: ServerProviderUsageWindow, now: number):
209 if (resetsAt === null) return null;214 if (resetsAt === null) return null;
210 return resetsAt <= now ? "resets now" : `resets in ${formatDuration(resetsAt - now)}`;215 return resetsAt <= now ? "resets now" : `resets in ${formatDuration(resetsAt - now)}`;
211}216}
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}