1import type { ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
2import {
3 CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
4 isClaudeResumeCompactionQuestion,
5} from "@t3tools/shared/claudeCompaction";
6import {
7 resolveSelectableProviderInstanceEntry,
8 type ProviderInstanceEntry,
9} from "../../providerInstances";
10import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils";
11
12// Used when the provider did not report its cache TTL: Claude's 1h cache plus slack.
13const CLAUDE_RESUME_COMPACTION_FALLBACK_MS = 70 * 60_000;
14const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000;
15
16export function providerSupportsManualCompaction(
17 provider: ProviderInstanceEntry | null | undefined,
18): boolean {
19 return provider?.snapshot.slashCommands.some((command) => command.name === "compact") ?? false;
20}
21
22export function hasAvailableCompactionProvider(input: {
23 readonly providers: ReadonlyArray<ProviderInstanceEntry>;
24 readonly driverKind: ProviderDriverKind;
25 readonly instanceId: ProviderInstanceId | null;
26 readonly lockedInstanceId: ProviderInstanceId | null;
27}): boolean {
28 const driverProviders = input.providers.filter(
29 (provider) => provider.driverKind === input.driverKind,
30 );
31 const lockedContinuationGroupKey = input.lockedInstanceId
32 ? driverProviders.find((provider) => provider.instanceId === input.lockedInstanceId)
33 ?.continuationGroupKey
34 : undefined;
35 const compatibleProviders = lockedContinuationGroupKey
36 ? driverProviders.filter(
37 (provider) => provider.continuationGroupKey === lockedContinuationGroupKey,
38 )
39 : driverProviders;
40
41 return providerSupportsManualCompaction(
42 resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined),
43 );
44}
45
46export function hasDismissedResumeCompaction(
47 activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>,
48): boolean {
49 return activities.some((activity) => {
50 if (activity.kind !== "user-input.resolved") return false;
51 const payload = activity.payload;
52 if (!payload || typeof payload !== "object") return false;
53 const answers = (payload as { readonly answers?: unknown }).answers;
54 if (!answers || typeof answers !== "object" || Array.isArray(answers)) return false;
55
56 return Object.entries(answers).some(
57 ([question, answer]) =>
58 isClaudeResumeCompactionQuestion(question) &&
59 answer === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
60 );
61 });
62}
63
64/** True once a large Claude context's prompt cache has expired, so the next
65 turn would re-read all of it uncached. */
66export function shouldOfferResumeCompaction(input: {
67 readonly provider: string | null | undefined;
68 readonly usedTokens: number | null | undefined;
69 readonly updatedAt: string | null | undefined;
70 readonly promptCacheTtlMs?: number | undefined;
71 readonly now: string;
72}): boolean {
73 if (
74 input.provider !== "claudeAgent" ||
75 (input.usedTokens ?? 0) < CLAUDE_RESUME_COMPACTION_TOKENS
76 ) {
77 return false;
78 }
79
80 const updatedAt = Date.parse(input.updatedAt ?? "");
81 const now = Date.parse(input.now);
82 return (
83 Number.isFinite(updatedAt) &&
84 Number.isFinite(now) &&
85 now - updatedAt >= (input.promptCacheTtlMs ?? CLAUDE_RESUME_COMPACTION_FALLBACK_MS)
86 );
87}
88
89export function resolveContextWindowModelDisplayName(
90 selection: ModelSelection | null | undefined,
91 modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>,
92): string | null {
93 if (!selection) {
94 return null;
95 }
96
97 const selectedModel = modelOptionsByInstance
98 .get(selection.instanceId)
99 ?.find((model) => model.slug === selection.model);
100
101 return selectedModel ? getTriggerDisplayModelName(selectedModel) : selection.model;
102}
103
104export function formatContextWindowCompactionMessage(
105 modelDisplayName: string | null | undefined,
106 autoCompactThreshold?: number | null,
107): string {
108 if (typeof autoCompactThreshold === "number" && autoCompactThreshold > 0) {
109 return `Compacts automatically at ${autoCompactThreshold.toLocaleString("en-US")} tokens.`;
110 }
111 return modelDisplayName
112 ? `Context for ${modelDisplayName} compacts automatically when needed.`
113 : "Context compacts automatically when needed.";
114}
115
116/**
117 * Whether the footer should hold the meter's slot before a snapshot exists.
118 *
119 * The snapshot comes from thread activities, which load after the shell.
120 * Reserving the slot while the detail loads, for a started thread, keeps the
121 * attach button still until the meter mounts. Once the detail is in, a
122 * missing snapshot means there is no usage to show and nothing is reserved.
123 *
124 * The meter renders from stored activities whatever the provider's state, so
125 * only a provider known not to stream usage skips the reservation. An unknown
126 * provider (catalog still loading, or the thread's provider disabled) reserves.
127 */
128export function shouldReserveContextWindowMeter(input: {
129 readonly meterEnabled: boolean;
130 readonly detailLoading: boolean;
131 readonly threadStarted: boolean;
132 /** `null` while the thread's provider is not in the catalog. */
133 readonly providerReportsContextWindow: boolean | null;
134}): boolean {
135 return (
136 input.meterEnabled &&
137 input.detailLoading &&
138 input.threadStarted &&
139 input.providerReportsContextWindow !== false
140 );
141}
142
143export function formatContextWindowCost(cost: {
144 readonly amount: number;
145 readonly currency: string;
146}): string {
147 const fractionDigits = Math.abs(cost.amount) > 0 && Math.abs(cost.amount) < 0.01 ? 4 : 2;
148 return `${cost.currency} ${cost.amount.toFixed(fractionDigits)}`;
149}