talon-agent 3.17.0 → 3.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/backend/codex/plan-usage.ts +8 -0
- package/src/backend/kilo/factory.ts +2 -19
- package/src/backend/opencode/factory.ts +2 -15
- package/src/backend/remote-server/model-catalog/presentation.ts +102 -25
- package/src/backend/remote-server/model-catalog/provider.ts +10 -6
- package/src/core/agent-runtime/capabilities.ts +2 -0
- package/src/frontend/discord/callbacks/components.ts +245 -17
- package/src/frontend/discord/commands/settings.ts +10 -18
- package/src/frontend/discord/helpers.ts +6 -0
- package/src/frontend/discord/model-picker.ts +234 -0
- package/src/frontend/shared/status-context.ts +4 -1
- package/src/frontend/telegram/helpers/diagnostics.ts +6 -0
package/package.json
CHANGED
|
@@ -95,6 +95,10 @@ export function parseCodexUsage(body: unknown): PlanUsage | undefined {
|
|
|
95
95
|
primary_window?: RawWindow | null;
|
|
96
96
|
secondary_window?: RawWindow | null;
|
|
97
97
|
};
|
|
98
|
+
rate_limit_reset_credits?: {
|
|
99
|
+
available_count?: number;
|
|
100
|
+
applicable_available_count?: number;
|
|
101
|
+
} | null;
|
|
98
102
|
} | null;
|
|
99
103
|
const limit = data?.rate_limit;
|
|
100
104
|
if (!limit) return undefined;
|
|
@@ -108,6 +112,10 @@ export function parseCodexUsage(body: unknown): PlanUsage | undefined {
|
|
|
108
112
|
return {
|
|
109
113
|
...(data?.plan_type ? { plan: data.plan_type } : {}),
|
|
110
114
|
windows,
|
|
115
|
+
...(typeof data?.rate_limit_reset_credits?.available_count === "number" &&
|
|
116
|
+
data.rate_limit_reset_credits.available_count > 0
|
|
117
|
+
? { resetsAvailable: data.rate_limit_reset_credits.available_count }
|
|
118
|
+
: {}),
|
|
111
119
|
fetchedAt: Date.now(),
|
|
112
120
|
};
|
|
113
121
|
}
|
|
@@ -69,25 +69,8 @@ const kiloFactory: BackendFactory = {
|
|
|
69
69
|
// through to `config.backendDefaults.kilo`.
|
|
70
70
|
getDefaultModelId: () => undefined,
|
|
71
71
|
getRawModelInfo: (id) => kiloGetModelInfo(id),
|
|
72
|
-
getSettingsPresentation:
|
|
73
|
-
|
|
74
|
-
// wrap into the canonical `ModelPickerResult`. Kilo doesn't
|
|
75
|
-
// expose pagination or a free-tier filter so the result is
|
|
76
|
-
// always page 1 of 1 with filter "all".
|
|
77
|
-
const inner = await kiloGetSettingsPresentation(
|
|
78
|
-
m,
|
|
79
|
-
options?.callbackPrefix,
|
|
80
|
-
);
|
|
81
|
-
return {
|
|
82
|
-
...inner,
|
|
83
|
-
view: "models" as const,
|
|
84
|
-
page: 1,
|
|
85
|
-
totalPages: 1,
|
|
86
|
-
filter: "all" as const,
|
|
87
|
-
freeCount: 0,
|
|
88
|
-
totalCount: inner.modelButtons.length,
|
|
89
|
-
};
|
|
90
|
-
},
|
|
72
|
+
getSettingsPresentation: (m, options) =>
|
|
73
|
+
kiloGetSettingsPresentation(m, options),
|
|
91
74
|
getProviders: () => kiloGetProviders(),
|
|
92
75
|
getProviderModels: (p, pg, ps) => kiloGetProviderModels(p, pg, ps),
|
|
93
76
|
formatModelError: (q, r) => kiloFormatModelError(q, r),
|
|
@@ -66,21 +66,8 @@ const opencodeFactory: BackendFactory = {
|
|
|
66
66
|
// Catalog-driven backend with no canonical default.
|
|
67
67
|
getDefaultModelId: () => undefined,
|
|
68
68
|
getRawModelInfo: (id) => ocGetModelInfo(id),
|
|
69
|
-
getSettingsPresentation:
|
|
70
|
-
|
|
71
|
-
m,
|
|
72
|
-
options?.callbackPrefix,
|
|
73
|
-
);
|
|
74
|
-
return {
|
|
75
|
-
...inner,
|
|
76
|
-
view: "models" as const,
|
|
77
|
-
page: 1,
|
|
78
|
-
totalPages: 1,
|
|
79
|
-
filter: "all" as const,
|
|
80
|
-
freeCount: 0,
|
|
81
|
-
totalCount: inner.modelButtons.length,
|
|
82
|
-
};
|
|
83
|
-
},
|
|
69
|
+
getSettingsPresentation: (m, options) =>
|
|
70
|
+
ocGetSettingsPresentation(m, options),
|
|
84
71
|
getProviders: () => ocGetProviders(),
|
|
85
72
|
getProviderModels: (p, pg, ps) => ocGetProviderModels(p, pg, ps),
|
|
86
73
|
formatModelError: (q, r) => ocFormatModelError(q, r),
|
|
@@ -35,8 +35,40 @@ export interface RemotePresentationOptions {
|
|
|
35
35
|
* true where the transport accepts arbitrary characters (Discord).
|
|
36
36
|
*/
|
|
37
37
|
allowCallbackSeparators: boolean;
|
|
38
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Default page size when the frontend doesn't ask for one — also the
|
|
40
|
+
* legacy quick-pick count (Telegram 4, Discord 24).
|
|
41
|
+
*/
|
|
39
42
|
quickPickLimit: number;
|
|
43
|
+
/**
|
|
44
|
+
* Flat model lists longer than this collapse into provider chips, so a
|
|
45
|
+
* catalog of several hundred models opens as a handful of providers
|
|
46
|
+
* rather than dozens of indistinguishable pages. Defaults to 60.
|
|
47
|
+
*/
|
|
48
|
+
groupThreshold?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Paging / filtering knobs, mirroring `ModelPickerOptions`. */
|
|
52
|
+
export interface RemotePickerOptions {
|
|
53
|
+
callbackPrefix?: string;
|
|
54
|
+
navCallbackPrefix?: string;
|
|
55
|
+
page?: number;
|
|
56
|
+
pageSize?: number;
|
|
57
|
+
filter?: "all" | "free";
|
|
58
|
+
provider?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What the frontend needs to render one page of the picker. */
|
|
62
|
+
export interface RemotePickerResult {
|
|
63
|
+
modelButtons: Array<ModelButton>;
|
|
64
|
+
modelDetails: Array<string>;
|
|
65
|
+
view: "groups" | "models";
|
|
66
|
+
page: number;
|
|
67
|
+
totalPages: number;
|
|
68
|
+
filter: "all" | "free";
|
|
69
|
+
freeCount: number;
|
|
70
|
+
totalCount: number;
|
|
71
|
+
provider?: string;
|
|
40
72
|
}
|
|
41
73
|
|
|
42
74
|
function getAvailabilityLabel(model: RemoteModelCatalogEntry) {
|
|
@@ -69,8 +101,8 @@ export interface RemoteModelPresentation {
|
|
|
69
101
|
): Array<RemoteModelCatalogEntry>;
|
|
70
102
|
getSettingsPresentation(
|
|
71
103
|
activeModel: string,
|
|
72
|
-
|
|
73
|
-
): Promise<
|
|
104
|
+
options?: RemotePickerOptions,
|
|
105
|
+
): Promise<RemotePickerResult>;
|
|
74
106
|
renderModelSummary(
|
|
75
107
|
activeModel: string,
|
|
76
108
|
defaultModel: string,
|
|
@@ -92,6 +124,7 @@ export function createRemoteModelPresentation(
|
|
|
92
124
|
maxCallbackIdLength,
|
|
93
125
|
allowCallbackSeparators,
|
|
94
126
|
quickPickLimit,
|
|
127
|
+
groupThreshold = 60,
|
|
95
128
|
} = options;
|
|
96
129
|
|
|
97
130
|
function isCallbackSafeModelID(modelID: string): boolean {
|
|
@@ -140,30 +173,64 @@ export function createRemoteModelPresentation(
|
|
|
140
173
|
|
|
141
174
|
async function getSettingsPresentation(
|
|
142
175
|
activeModel: string,
|
|
143
|
-
|
|
144
|
-
): Promise<{
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}> {
|
|
176
|
+
options: RemotePickerOptions = {},
|
|
177
|
+
): Promise<RemotePickerResult> {
|
|
178
|
+
const callbackPrefix = options.callbackPrefix ?? "settings:model:";
|
|
179
|
+
const navPrefix = options.navCallbackPrefix ?? "settings:models";
|
|
148
180
|
const catalog = await getCatalog();
|
|
149
181
|
const current = getRemoteModelInfo(catalog, activeModel);
|
|
150
|
-
const picks = getQuickPickModels(catalog, activeModel);
|
|
151
182
|
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
183
|
+
const filter = options.filter === "free" ? "free" : "all";
|
|
184
|
+
const selectable = catalog.connectedModels.filter((m) =>
|
|
185
|
+
isCallbackSafeModelID(m.id),
|
|
186
|
+
);
|
|
187
|
+
const freeCount = selectable.filter((m) => m.free).length;
|
|
188
|
+
const scoped = selectable.filter(
|
|
189
|
+
(m) =>
|
|
190
|
+
(filter === "all" || m.free) &&
|
|
191
|
+
(!options.provider || m.providerID === options.provider),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// A remote catalog can run to hundreds of models, and only the first
|
|
195
|
+
// page's worth ever fit on screen — so offer the provider list as the
|
|
196
|
+
// way in when nothing narrower was asked for and the flat list would be
|
|
197
|
+
// unreadable anyway.
|
|
198
|
+
const providers = catalog.connectedProviders;
|
|
199
|
+
const asGroups =
|
|
200
|
+
!options.provider &&
|
|
201
|
+
providers.length > 1 &&
|
|
202
|
+
scoped.length > groupThreshold;
|
|
203
|
+
|
|
204
|
+
const pageSize = Math.max(1, options.pageSize ?? quickPickLimit);
|
|
205
|
+
const source = asGroups ? providers : scoped;
|
|
206
|
+
const totalPages = Math.max(1, Math.ceil(source.length / pageSize));
|
|
207
|
+
const page = Math.min(Math.max(1, options.page ?? 1), totalPages);
|
|
208
|
+
const slice = source.slice((page - 1) * pageSize, page * pageSize);
|
|
209
|
+
|
|
210
|
+
const modelButtons: Array<ModelButton> = asGroups
|
|
211
|
+
? (slice as typeof providers).map((p) => ({
|
|
212
|
+
text: `${p.name} (${p.modelCount})`,
|
|
213
|
+
callback_data: `${navPrefix}:provider:${p.id}`,
|
|
214
|
+
}))
|
|
215
|
+
: (slice as typeof scoped).map((m) => {
|
|
216
|
+
const btnLabel =
|
|
217
|
+
m.id.length <= 20 ? m.id : m.name.length <= 20 ? m.name : m.id;
|
|
218
|
+
const txt = m.free ? `${btnLabel} ★` : btnLabel;
|
|
219
|
+
const sel =
|
|
220
|
+
current &&
|
|
221
|
+
m.id === current.id &&
|
|
222
|
+
m.providerID === current.providerID;
|
|
223
|
+
return {
|
|
224
|
+
text: sel ? `✓ ${txt}` : txt,
|
|
225
|
+
callback_data: `${callbackPrefix}${m.id}`,
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
if (!asGroups) {
|
|
229
|
+
modelButtons.push({
|
|
230
|
+
text: "Reset",
|
|
231
|
+
callback_data: `${callbackPrefix}reset`,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
167
234
|
|
|
168
235
|
const details: Array<string> = [];
|
|
169
236
|
if (current) {
|
|
@@ -189,7 +256,17 @@ export function createRemoteModelPresentation(
|
|
|
189
256
|
);
|
|
190
257
|
}
|
|
191
258
|
details.push("Hint: use /model <name> to switch.");
|
|
192
|
-
return {
|
|
259
|
+
return {
|
|
260
|
+
modelButtons,
|
|
261
|
+
modelDetails: details,
|
|
262
|
+
view: asGroups ? "groups" : "models",
|
|
263
|
+
page,
|
|
264
|
+
totalPages,
|
|
265
|
+
filter,
|
|
266
|
+
freeCount,
|
|
267
|
+
totalCount: selectable.length,
|
|
268
|
+
...(options.provider ? { provider: options.provider } : {}),
|
|
269
|
+
};
|
|
193
270
|
}
|
|
194
271
|
|
|
195
272
|
async function renderModelSummary(
|
|
@@ -19,6 +19,10 @@ import type {
|
|
|
19
19
|
RemoteModelCatalogEntry,
|
|
20
20
|
RemoteModelResolution,
|
|
21
21
|
} from "./types.js";
|
|
22
|
+
import type {
|
|
23
|
+
RemotePickerOptions,
|
|
24
|
+
RemotePickerResult,
|
|
25
|
+
} from "./presentation.js";
|
|
22
26
|
|
|
23
27
|
export interface RemoteModelProviderDeps {
|
|
24
28
|
/** Human label — "OpenCode" / "Kilo" — used in error strings. */
|
|
@@ -36,8 +40,8 @@ export interface RemoteModelProviderDeps {
|
|
|
36
40
|
formatUnavailableModel(model: RemoteModelCatalogEntry): string;
|
|
37
41
|
getSettingsPresentation(
|
|
38
42
|
activeModel: string,
|
|
39
|
-
|
|
40
|
-
): Promise<
|
|
43
|
+
options?: RemotePickerOptions,
|
|
44
|
+
): Promise<RemotePickerResult>;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
export interface RemoteModelProvider {
|
|
@@ -45,8 +49,8 @@ export interface RemoteModelProvider {
|
|
|
45
49
|
getModelInfo(id: string): Promise<UnifiedModelInfo | undefined>;
|
|
46
50
|
getSettingsPresentation(
|
|
47
51
|
activeModel: string,
|
|
48
|
-
|
|
49
|
-
): Promise<
|
|
52
|
+
options?: RemotePickerOptions,
|
|
53
|
+
): Promise<RemotePickerResult>;
|
|
50
54
|
getProviders(): Promise<UnifiedProviderInfo[]>;
|
|
51
55
|
getProviderModels(
|
|
52
56
|
providerId: string,
|
|
@@ -118,8 +122,8 @@ export function createRemoteModelProvider(
|
|
|
118
122
|
return entry ? toUnifiedModelInfo(entry) : undefined;
|
|
119
123
|
},
|
|
120
124
|
|
|
121
|
-
getSettingsPresentation(activeModel,
|
|
122
|
-
return deps.getSettingsPresentation(activeModel,
|
|
125
|
+
getSettingsPresentation(activeModel, options) {
|
|
126
|
+
return deps.getSettingsPresentation(activeModel, options);
|
|
123
127
|
},
|
|
124
128
|
|
|
125
129
|
async getProviders() {
|
|
@@ -245,6 +245,8 @@ export interface PlanUsage {
|
|
|
245
245
|
/** Subscription tier (`max`, `pro`, …) when known. */
|
|
246
246
|
plan?: string;
|
|
247
247
|
windows: PlanWindow[];
|
|
248
|
+
/** How many one-shot rate-limit resets are still banked, when the plan has them. */
|
|
249
|
+
resetsAvailable?: number;
|
|
248
250
|
/** Epoch ms of the read, so renderers can flag figures as aged. */
|
|
249
251
|
fetchedAt: number;
|
|
250
252
|
}
|
|
@@ -29,6 +29,14 @@ import {
|
|
|
29
29
|
} from "discord.js";
|
|
30
30
|
import type { TalonConfig } from "../../../util/config.js";
|
|
31
31
|
import type { Gateway } from "../../../core/engine/gateway.js";
|
|
32
|
+
import {
|
|
33
|
+
buildModelPickerView,
|
|
34
|
+
buildBackendPickerView,
|
|
35
|
+
decodeModelNav,
|
|
36
|
+
MODEL_NAV_PREFIX,
|
|
37
|
+
MODEL_PAGE_SIZE,
|
|
38
|
+
} from "../model-picker.js";
|
|
39
|
+
import { metricsViewRow, renderMetricsView } from "../commands/admin.js";
|
|
32
40
|
import {
|
|
33
41
|
getChatSettings,
|
|
34
42
|
setChatModelForBackend,
|
|
@@ -52,7 +60,12 @@ import { execute } from "../../../core/engine/dispatcher.js";
|
|
|
52
60
|
import {
|
|
53
61
|
getBackendIdForChat,
|
|
54
62
|
resolveChatBackend,
|
|
63
|
+
listAvailableBackends,
|
|
64
|
+
rebindChat,
|
|
55
65
|
} from "../../../core/engine/backend-controller/index.js";
|
|
66
|
+
import { resetSession } from "../../../storage/sessions.js";
|
|
67
|
+
import { clearHistory } from "../../../storage/history.js";
|
|
68
|
+
import { resetPulseCheckpoint } from "../../../core/background/pulse.js";
|
|
56
69
|
import { resolveActiveModelForChat } from "../../../core/models/active-model.js";
|
|
57
70
|
import {
|
|
58
71
|
displayReasoningEffort,
|
|
@@ -68,7 +81,6 @@ import {
|
|
|
68
81
|
DISCORD_MAX_TEXT,
|
|
69
82
|
} from "../formatting.js";
|
|
70
83
|
import { chatIdFromInteraction, type ComponentInteraction } from "./shared.js";
|
|
71
|
-
import { metricsViewRow, renderMetricsView } from "../commands/admin.js";
|
|
72
84
|
|
|
73
85
|
export async function handleComponentInteraction(
|
|
74
86
|
interaction: ComponentInteraction,
|
|
@@ -287,6 +299,38 @@ export async function handleComponentInteraction(
|
|
|
287
299
|
const be = resolveChatBackend(chatId, gateway?.backend);
|
|
288
300
|
const beId = getBackendIdForChat(chatId);
|
|
289
301
|
|
|
302
|
+
// In the provider view the select carries chips, not models — picking
|
|
303
|
+
// one drills into that provider rather than choosing anything.
|
|
304
|
+
if (value?.startsWith("provider:") && be?.models?.getSettingsPresentation) {
|
|
305
|
+
const providerId = value.slice("provider:".length);
|
|
306
|
+
const { model: activeNow } = await resolveActiveModelForChat(
|
|
307
|
+
chatId,
|
|
308
|
+
be,
|
|
309
|
+
beId,
|
|
310
|
+
config,
|
|
311
|
+
);
|
|
312
|
+
const pres = await be.models.getSettingsPresentation(activeNow ?? "", {
|
|
313
|
+
callbackPrefix: "model:",
|
|
314
|
+
navCallbackPrefix: MODEL_NAV_PREFIX,
|
|
315
|
+
pageSize: MODEL_PAGE_SIZE,
|
|
316
|
+
provider: providerId,
|
|
317
|
+
});
|
|
318
|
+
const view = buildModelPickerView(
|
|
319
|
+
pres,
|
|
320
|
+
activeNow ?? "_No model selected_",
|
|
321
|
+
beId,
|
|
322
|
+
);
|
|
323
|
+
try {
|
|
324
|
+
await interaction.update({
|
|
325
|
+
content: view.content,
|
|
326
|
+
components: view.components,
|
|
327
|
+
});
|
|
328
|
+
} catch {
|
|
329
|
+
/* ignore */
|
|
330
|
+
}
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
290
334
|
if (value === "reset") {
|
|
291
335
|
// Clear THIS backend's slot only — other backends stay intact.
|
|
292
336
|
setChatModelForBackend(chatId, beId, undefined);
|
|
@@ -320,35 +364,219 @@ export async function handleComponentInteraction(
|
|
|
320
364
|
const current = resolvedCurrent ?? "";
|
|
321
365
|
const pres = await be.models?.getSettingsPresentation(current, {
|
|
322
366
|
callbackPrefix: "model:",
|
|
367
|
+
navCallbackPrefix: MODEL_NAV_PREFIX,
|
|
368
|
+
pageSize: MODEL_PAGE_SIZE,
|
|
323
369
|
});
|
|
324
370
|
const modelInfo = resolvedCurrent
|
|
325
371
|
? await be.models?.getRawModelInfo?.(resolvedCurrent)
|
|
326
372
|
: undefined;
|
|
327
373
|
const displayName =
|
|
328
374
|
modelInfo?.displayName ?? resolvedCurrent ?? "_No model selected_";
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
.
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
375
|
+
const view = buildModelPickerView(pres, displayName, beId);
|
|
376
|
+
try {
|
|
377
|
+
await interaction.update({
|
|
378
|
+
content: view.content,
|
|
379
|
+
components: view.components,
|
|
380
|
+
});
|
|
381
|
+
} catch {
|
|
382
|
+
/* ignore */
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── /model pager — page arrows, free-tier toggle, provider drill ────────
|
|
389
|
+
// Pure navigation: re-render the same panel at the requested page without
|
|
390
|
+
// touching the chat's selected model.
|
|
391
|
+
if (customId.startsWith(`${MODEL_NAV_PREFIX}:`)) {
|
|
392
|
+
// Backend submenu — the one nav action that isn't a re-page.
|
|
393
|
+
if (customId === `${MODEL_NAV_PREFIX}:backends`) {
|
|
394
|
+
const view = buildBackendPickerView(
|
|
395
|
+
listAvailableBackends(config),
|
|
396
|
+
getBackendIdForChat(chatId),
|
|
341
397
|
);
|
|
342
398
|
try {
|
|
343
399
|
await interaction.update({
|
|
344
|
-
content:
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
400
|
+
content: view.content,
|
|
401
|
+
components: view.components,
|
|
402
|
+
});
|
|
403
|
+
} catch {
|
|
404
|
+
/* ignore */
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const target = decodeModelNav(customId);
|
|
410
|
+
if (!target) {
|
|
411
|
+
try {
|
|
412
|
+
await interaction.deferUpdate();
|
|
413
|
+
} catch {
|
|
414
|
+
/* ignore */
|
|
415
|
+
}
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const be = resolveChatBackend(chatId, gateway?.backend);
|
|
419
|
+
const beId = getBackendIdForChat(chatId);
|
|
420
|
+
const { model: current } = await resolveActiveModelForChat(
|
|
421
|
+
chatId,
|
|
422
|
+
be,
|
|
423
|
+
beId,
|
|
424
|
+
config,
|
|
425
|
+
);
|
|
426
|
+
if (!be?.models?.getSettingsPresentation) {
|
|
427
|
+
try {
|
|
428
|
+
await interaction.deferUpdate();
|
|
429
|
+
} catch {
|
|
430
|
+
/* ignore */
|
|
431
|
+
}
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const pres = await be.models.getSettingsPresentation(current ?? "", {
|
|
435
|
+
callbackPrefix: "model:",
|
|
436
|
+
navCallbackPrefix: MODEL_NAV_PREFIX,
|
|
437
|
+
pageSize: MODEL_PAGE_SIZE,
|
|
438
|
+
page: target.page,
|
|
439
|
+
filter: target.filter,
|
|
440
|
+
...(target.provider ? { provider: target.provider } : {}),
|
|
441
|
+
});
|
|
442
|
+
const modelInfo = current
|
|
443
|
+
? await be.models?.getRawModelInfo?.(current)
|
|
444
|
+
: undefined;
|
|
445
|
+
const view = buildModelPickerView(
|
|
446
|
+
pres,
|
|
447
|
+
modelInfo?.displayName ?? current ?? "_No model selected_",
|
|
448
|
+
beId,
|
|
449
|
+
);
|
|
450
|
+
try {
|
|
451
|
+
await interaction.update({
|
|
452
|
+
content: view.content,
|
|
453
|
+
components: view.components,
|
|
454
|
+
});
|
|
455
|
+
} catch {
|
|
456
|
+
/* ignore */
|
|
457
|
+
}
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ── /model backend switch ───────────────────────────────────────────────
|
|
462
|
+
// Rebinding drops this chat's session (it isn't portable across backends)
|
|
463
|
+
// but keeps each backend's remembered model pick, so switching back
|
|
464
|
+
// restores what was selected there before.
|
|
465
|
+
if (customId === "model:backend-select" && interaction.isStringSelectMenu()) {
|
|
466
|
+
const backendId = interaction.values[0] ?? "";
|
|
467
|
+
const available = listAvailableBackends(config);
|
|
468
|
+
if (!available.some((b) => b.id === backendId)) {
|
|
469
|
+
await interaction.reply({
|
|
470
|
+
content: "Backend not available.",
|
|
471
|
+
flags: MessageFlags.Ephemeral,
|
|
472
|
+
});
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Booting a cold backend takes seconds — Kilo and OpenCode spawn a server
|
|
477
|
+
// and sweep MCP registrations — and Discord drops an interaction that
|
|
478
|
+
// isn't acknowledged within three. Ack first, edit the panel after.
|
|
479
|
+
try {
|
|
480
|
+
await interaction.deferUpdate();
|
|
481
|
+
} catch {
|
|
482
|
+
/* already acknowledged */
|
|
483
|
+
}
|
|
484
|
+
const fail = async (text: string): Promise<void> => {
|
|
485
|
+
try {
|
|
486
|
+
await interaction.followUp({
|
|
487
|
+
content: text,
|
|
488
|
+
flags: MessageFlags.Ephemeral,
|
|
348
489
|
});
|
|
349
490
|
} catch {
|
|
350
491
|
/* ignore */
|
|
351
492
|
}
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
// Resolve the outgoing backend before rebinding — afterwards the chat
|
|
496
|
+
// already points at the new one and the old instance is unreachable.
|
|
497
|
+
const previousId = getBackendIdForChat(chatId);
|
|
498
|
+
const previous = resolveChatBackend(chatId, gateway?.backend);
|
|
499
|
+
const alreadyThere = previousId === backendId;
|
|
500
|
+
const result = alreadyThere
|
|
501
|
+
? { ok: true as const }
|
|
502
|
+
: await rebindChat(chatId, backendId, config);
|
|
503
|
+
if (!result.ok) {
|
|
504
|
+
await fail(result.error?.slice(0, 200) ?? "Rebind failed.");
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
setChatBackend(chatId, backendId);
|
|
508
|
+
|
|
509
|
+
// The pool accepts a backend whose SDK is installed, but the SDK may only
|
|
510
|
+
// be a client for a CLI that isn't (OpenCode and Kilo spawn `opencode` /
|
|
511
|
+
// `kilo`). That surfaces here, on the first real call. Reading the
|
|
512
|
+
// catalog is the cheapest way to find out, and it is needed for the panel
|
|
513
|
+
// anyway — so treat a throw as "this backend can't serve" and put the
|
|
514
|
+
// chat back where it was rather than stranding it somewhere unusable.
|
|
515
|
+
let next = resolveChatBackend(chatId, gateway?.backend);
|
|
516
|
+
let newModel: string | undefined;
|
|
517
|
+
let pres;
|
|
518
|
+
try {
|
|
519
|
+
newModel =
|
|
520
|
+
(await resolveActiveModelForChat(chatId, next, backendId, config))
|
|
521
|
+
.model ?? undefined;
|
|
522
|
+
if (next?.models?.getSettingsPresentation) {
|
|
523
|
+
pres = await next.models.getSettingsPresentation(newModel ?? "", {
|
|
524
|
+
callbackPrefix: "model:",
|
|
525
|
+
navCallbackPrefix: MODEL_NAV_PREFIX,
|
|
526
|
+
pageSize: MODEL_PAGE_SIZE,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
} catch (err) {
|
|
530
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
531
|
+
logError("discord", `Backend switch to ${backendId} failed`, err);
|
|
532
|
+
if (!alreadyThere) {
|
|
533
|
+
await rebindChat(chatId, previousId, config).catch(() => undefined);
|
|
534
|
+
setChatBackend(chatId, previousId);
|
|
535
|
+
}
|
|
536
|
+
await fail(
|
|
537
|
+
`⚠️ \`${backendId}\` could not start — staying on \`${previousId}\`.\n` +
|
|
538
|
+
`\`\`\`\n${safeSlice(reason, 300)}\n\`\`\``,
|
|
539
|
+
);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Only now is the switch known to hold. Session state doesn't port across
|
|
544
|
+
// backends, so it goes — but each backend's remembered model pick stays.
|
|
545
|
+
// A re-pick of the backend already in use clears nothing: the retry after
|
|
546
|
+
// a timed-out interaction must not cost the session a second time.
|
|
547
|
+
if (!alreadyThere) {
|
|
548
|
+
resetSession(chatId);
|
|
549
|
+
clearHistory(chatId);
|
|
550
|
+
resetPulseCheckpoint(chatId);
|
|
551
|
+
previous?.sessions?.resetChat?.(chatId);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
next = resolveChatBackend(chatId, gateway?.backend);
|
|
555
|
+
// Warming is fire-and-forget: it can take seconds on OpenCode/Kilo and
|
|
556
|
+
// the interaction still has a panel to redraw.
|
|
557
|
+
if (next && next !== previous) {
|
|
558
|
+
void Promise.resolve(next.sessions?.warmSession?.(chatId)).catch(
|
|
559
|
+
() => undefined,
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
try {
|
|
564
|
+
await interaction.editReply(
|
|
565
|
+
pres
|
|
566
|
+
? {
|
|
567
|
+
...buildModelPickerView(
|
|
568
|
+
pres,
|
|
569
|
+
newModel ?? "_No model selected_",
|
|
570
|
+
backendId,
|
|
571
|
+
),
|
|
572
|
+
}
|
|
573
|
+
: {
|
|
574
|
+
content: `**Backend:** \`${backendId}\` · model \`${newModel ?? "none"}\``,
|
|
575
|
+
components: [],
|
|
576
|
+
},
|
|
577
|
+
);
|
|
578
|
+
} catch {
|
|
579
|
+
/* ignore */
|
|
352
580
|
}
|
|
353
581
|
return;
|
|
354
582
|
}
|
|
@@ -46,6 +46,11 @@ import {
|
|
|
46
46
|
import { resolveActiveModelForChat } from "../../../core/models/active-model.js";
|
|
47
47
|
import { safeSlice } from "../formatting.js";
|
|
48
48
|
import { reply } from "./shared.js";
|
|
49
|
+
import {
|
|
50
|
+
buildModelPickerView,
|
|
51
|
+
MODEL_NAV_PREFIX,
|
|
52
|
+
MODEL_PAGE_SIZE,
|
|
53
|
+
} from "../model-picker.js";
|
|
49
54
|
|
|
50
55
|
export async function handleModel(
|
|
51
56
|
i: ChatInputCommandInteraction,
|
|
@@ -94,6 +99,8 @@ export async function handleModel(
|
|
|
94
99
|
if (be?.models?.getSettingsPresentation) {
|
|
95
100
|
const pres = await be.models?.getSettingsPresentation(activeModel, {
|
|
96
101
|
callbackPrefix: "model:",
|
|
102
|
+
navCallbackPrefix: MODEL_NAV_PREFIX,
|
|
103
|
+
pageSize: MODEL_PAGE_SIZE,
|
|
97
104
|
});
|
|
98
105
|
const modelInfo = activeModel
|
|
99
106
|
? await be.models?.getRawModelInfo?.(activeModel)
|
|
@@ -102,25 +109,10 @@ export async function handleModel(
|
|
|
102
109
|
modelInfo?.displayName ??
|
|
103
110
|
(activeModel ? formatModelLabel(activeModel) : "_No model selected_");
|
|
104
111
|
|
|
105
|
-
|
|
106
|
-
// options at 25; if the backend exposes more, use `/model name:<value>`
|
|
107
|
-
// (autocomplete-backed) to pick anything outside this list.
|
|
108
|
-
const options = pres.modelButtons.slice(0, 25).map((b) => ({
|
|
109
|
-
label: safeSlice(b.text.replace(/^✓ /, ""), 100),
|
|
110
|
-
value: safeSlice(b.callback_data.replace(/^model:/, ""), 100),
|
|
111
|
-
default: b.text.startsWith("✓"),
|
|
112
|
-
}));
|
|
113
|
-
const menu = new StringSelectMenuBuilder()
|
|
114
|
-
.setCustomId("model:select")
|
|
115
|
-
.setPlaceholder("Pick a model")
|
|
116
|
-
.addOptions(options);
|
|
117
|
-
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
|
118
|
-
menu,
|
|
119
|
-
);
|
|
120
|
-
const lines = [`**Model:** \`${displayName}\``, ...pres.modelDetails];
|
|
112
|
+
const view = buildModelPickerView(pres, displayName, beId);
|
|
121
113
|
await i.editReply({
|
|
122
|
-
content:
|
|
123
|
-
components:
|
|
114
|
+
content: view.content,
|
|
115
|
+
components: view.components,
|
|
124
116
|
allowedMentions: { parse: [] },
|
|
125
117
|
});
|
|
126
118
|
} else {
|
|
@@ -293,6 +293,12 @@ export function renderUsageMessage(entries: BackendUsageEntry[]): string {
|
|
|
293
293
|
const age = entry.plan.ageLabel ? ` *(${entry.plan.ageLabel})*` : "";
|
|
294
294
|
const plan = entry.plan.plan ? ` · ${entry.plan.plan}` : "";
|
|
295
295
|
lines.push("", `**${name}**${plan}${age}`);
|
|
296
|
+
if (entry.plan.resetsAvailable) {
|
|
297
|
+
const n = entry.plan.resetsAvailable;
|
|
298
|
+
lines.push(
|
|
299
|
+
` • You have **${n}** usage limit reset${n === 1 ? "" : "s"} available`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
296
302
|
for (const w of entry.plan.windows) {
|
|
297
303
|
const reset = w.resetLabel ? ` reset ${w.resetLabel}` : "";
|
|
298
304
|
lines.push(
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model picker rendering — select menu plus pager controls.
|
|
3
|
+
*
|
|
4
|
+
* The backend already paginates and filters the catalog
|
|
5
|
+
* (`getSettingsPresentation` returns page / totalPages / filter / provider);
|
|
6
|
+
* this only lays that out as Discord components. A select menu caps at 25
|
|
7
|
+
* options, so without the pager everything past the first 25 models was
|
|
8
|
+
* unreachable from the UI.
|
|
9
|
+
*
|
|
10
|
+
* Navigation state rides in the custom_id — `model:nav:page:3:free` — which
|
|
11
|
+
* is what keeps the handler stateless across re-renders.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
ActionRowBuilder,
|
|
16
|
+
ButtonBuilder,
|
|
17
|
+
ButtonStyle,
|
|
18
|
+
StringSelectMenuBuilder,
|
|
19
|
+
type APIActionRowComponent,
|
|
20
|
+
type APIComponentInMessageActionRow,
|
|
21
|
+
} from "discord.js";
|
|
22
|
+
import type { ModelPickerResult } from "../../core/types.js";
|
|
23
|
+
import { safeSlice } from "./formatting.js";
|
|
24
|
+
|
|
25
|
+
export const MODEL_NAV_PREFIX = "model:nav";
|
|
26
|
+
/** Discord's hard cap on select-menu options — also the page size we request. */
|
|
27
|
+
export const MODEL_PAGE_SIZE = 25;
|
|
28
|
+
|
|
29
|
+
/** Where a nav button points. Decoded from a `model:nav:…` custom_id. */
|
|
30
|
+
export type ModelNavTarget = {
|
|
31
|
+
page: number;
|
|
32
|
+
filter: "all" | "free";
|
|
33
|
+
provider?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function decodeModelNav(customId: string): ModelNavTarget | null {
|
|
37
|
+
if (!customId.startsWith(`${MODEL_NAV_PREFIX}:`)) return null;
|
|
38
|
+
const parts = customId.slice(MODEL_NAV_PREFIX.length + 1).split(":");
|
|
39
|
+
const [kind] = parts;
|
|
40
|
+
|
|
41
|
+
if (kind === "providers") return { page: 1, filter: "all" };
|
|
42
|
+
if (kind === "provider") {
|
|
43
|
+
return { page: 1, filter: "all", provider: parts[1] };
|
|
44
|
+
}
|
|
45
|
+
if (kind === "page") {
|
|
46
|
+
const page = Number(parts[1]);
|
|
47
|
+
const filter = parts[2] === "free" ? "free" : "all";
|
|
48
|
+
return {
|
|
49
|
+
page: Number.isFinite(page) && page > 0 ? page : 1,
|
|
50
|
+
filter,
|
|
51
|
+
...(parts[3] ? { provider: parts[3] } : {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (kind === "filter") {
|
|
55
|
+
const filter = parts[1] === "free" ? "free" : "all";
|
|
56
|
+
return { page: 1, filter, ...(parts[2] ? { provider: parts[2] } : {}) };
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function navId(suffix: string, provider?: string): string {
|
|
62
|
+
const base = `${MODEL_NAV_PREFIX}:${suffix}`;
|
|
63
|
+
return provider ? `${base}:${provider}` : base;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Buttons under the select: page arrows, a page counter, the free-tier
|
|
68
|
+
* toggle, and a way back to the provider list. Five buttons is exactly one
|
|
69
|
+
* Action Row, so this never spills into a second row.
|
|
70
|
+
*/
|
|
71
|
+
function controlRow(
|
|
72
|
+
pres: ModelPickerResult,
|
|
73
|
+
): ActionRowBuilder<ButtonBuilder> | null {
|
|
74
|
+
const buttons: ButtonBuilder[] = [];
|
|
75
|
+
const { page, totalPages, filter, freeCount, provider } = pres;
|
|
76
|
+
|
|
77
|
+
if (totalPages > 1) {
|
|
78
|
+
buttons.push(
|
|
79
|
+
new ButtonBuilder()
|
|
80
|
+
.setCustomId(navId(`page:${page - 1}:${filter}`, provider))
|
|
81
|
+
.setLabel("◀")
|
|
82
|
+
.setStyle(ButtonStyle.Secondary)
|
|
83
|
+
.setDisabled(page <= 1),
|
|
84
|
+
// A disabled button is the only way to render a static counter
|
|
85
|
+
// inline with the arrows.
|
|
86
|
+
new ButtonBuilder()
|
|
87
|
+
.setCustomId(navId("noop"))
|
|
88
|
+
.setLabel(`${page}/${totalPages}`)
|
|
89
|
+
.setStyle(ButtonStyle.Secondary)
|
|
90
|
+
.setDisabled(true),
|
|
91
|
+
new ButtonBuilder()
|
|
92
|
+
.setCustomId(navId(`page:${page + 1}:${filter}`, provider))
|
|
93
|
+
.setLabel("▶")
|
|
94
|
+
.setStyle(ButtonStyle.Secondary)
|
|
95
|
+
.setDisabled(page >= totalPages),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// A filter that would keep everything is just a button that does nothing —
|
|
100
|
+
// offer it only when part of the catalog costs money.
|
|
101
|
+
if (freeCount > 0 && freeCount < pres.totalCount) {
|
|
102
|
+
buttons.push(
|
|
103
|
+
new ButtonBuilder()
|
|
104
|
+
.setCustomId(
|
|
105
|
+
navId(`filter:${filter === "free" ? "all" : "free"}`, provider),
|
|
106
|
+
)
|
|
107
|
+
.setLabel(filter === "free" ? `All models` : `Free only (${freeCount})`)
|
|
108
|
+
.setStyle(ButtonStyle.Secondary),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (provider) {
|
|
113
|
+
buttons.push(
|
|
114
|
+
new ButtonBuilder()
|
|
115
|
+
.setCustomId(navId("providers"))
|
|
116
|
+
.setLabel("← Providers")
|
|
117
|
+
.setStyle(ButtonStyle.Secondary),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (buttons.length === 0) return null;
|
|
122
|
+
return new ActionRowBuilder<ButtonBuilder>().addComponents(buttons);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ModelPickerView {
|
|
126
|
+
content: string;
|
|
127
|
+
components: APIActionRowComponent<APIComponentInMessageActionRow>[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Select of the backends config exposes, plus a way back to the models. */
|
|
131
|
+
export function buildBackendPickerView(
|
|
132
|
+
backends: { id: string; label: string }[],
|
|
133
|
+
currentId: string,
|
|
134
|
+
): ModelPickerView {
|
|
135
|
+
const menu = new StringSelectMenuBuilder()
|
|
136
|
+
.setCustomId("model:backend-select")
|
|
137
|
+
.setPlaceholder("Pick a backend")
|
|
138
|
+
.addOptions(
|
|
139
|
+
backends.map((b) => ({
|
|
140
|
+
label: safeSlice(b.label || b.id, 100),
|
|
141
|
+
value: safeSlice(b.id, 100),
|
|
142
|
+
description: b.id === currentId ? "current" : undefined,
|
|
143
|
+
default: b.id === currentId,
|
|
144
|
+
})),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const back = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
148
|
+
new ButtonBuilder()
|
|
149
|
+
.setCustomId(navId("page:1:all"))
|
|
150
|
+
.setLabel("← Models")
|
|
151
|
+
.setStyle(ButtonStyle.Secondary),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
content: [
|
|
156
|
+
`**Backend:** \`${currentId}\``,
|
|
157
|
+
"",
|
|
158
|
+
"_Switching clears this chat's session — each backend keeps its own model pick._",
|
|
159
|
+
].join("\n"),
|
|
160
|
+
components: [
|
|
161
|
+
new ActionRowBuilder<StringSelectMenuBuilder>()
|
|
162
|
+
.addComponents(menu)
|
|
163
|
+
.toJSON(),
|
|
164
|
+
back.toJSON(),
|
|
165
|
+
],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Lay out one page of the picker. In the `groups` view the select lists
|
|
171
|
+
* providers to drill into; in `models` it lists selectable models.
|
|
172
|
+
*/
|
|
173
|
+
export function buildModelPickerView(
|
|
174
|
+
pres: ModelPickerResult,
|
|
175
|
+
displayName: string,
|
|
176
|
+
backendId: string,
|
|
177
|
+
): ModelPickerView {
|
|
178
|
+
const isGroups = pres.view === "groups";
|
|
179
|
+
const options = pres.modelButtons.slice(0, MODEL_PAGE_SIZE).map((b) => ({
|
|
180
|
+
// Discord's own guidance is ~38 characters before a label starts
|
|
181
|
+
// truncating visually, well under the 100-character hard cap.
|
|
182
|
+
label: safeSlice(b.text.replace(/^✓ /, ""), 100),
|
|
183
|
+
value: safeSlice(
|
|
184
|
+
isGroups
|
|
185
|
+
? b.callback_data.replace(/^.*:provider:/, "provider:")
|
|
186
|
+
: b.callback_data.replace(/^model:/, ""),
|
|
187
|
+
100,
|
|
188
|
+
),
|
|
189
|
+
default: !isGroups && b.text.startsWith("✓"),
|
|
190
|
+
}));
|
|
191
|
+
|
|
192
|
+
const components: APIActionRowComponent<APIComponentInMessageActionRow>[] =
|
|
193
|
+
[];
|
|
194
|
+
|
|
195
|
+
if (options.length > 0) {
|
|
196
|
+
const menu = new StringSelectMenuBuilder()
|
|
197
|
+
.setCustomId("model:select")
|
|
198
|
+
.setPlaceholder(isGroups ? "Pick a provider" : "Pick a model")
|
|
199
|
+
.addOptions(options);
|
|
200
|
+
components.push(
|
|
201
|
+
new ActionRowBuilder<StringSelectMenuBuilder>()
|
|
202
|
+
.addComponents(menu)
|
|
203
|
+
.toJSON(),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const controls = controlRow(pres);
|
|
208
|
+
if (controls) components.push(controls.toJSON());
|
|
209
|
+
|
|
210
|
+
// Own row: the control row is already at Discord's five-button ceiling
|
|
211
|
+
// whenever the catalog pages.
|
|
212
|
+
components.push(
|
|
213
|
+
new ActionRowBuilder<ButtonBuilder>()
|
|
214
|
+
.addComponents(
|
|
215
|
+
new ButtonBuilder()
|
|
216
|
+
.setCustomId(navId("backends"))
|
|
217
|
+
.setLabel(`⚙ Backend: ${backendId}`)
|
|
218
|
+
.setStyle(ButtonStyle.Secondary),
|
|
219
|
+
)
|
|
220
|
+
.toJSON(),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const scope = pres.provider ? ` · ${pres.provider}` : "";
|
|
224
|
+
const counts =
|
|
225
|
+
pres.totalPages > 1
|
|
226
|
+
? ` · ${pres.totalCount} model${pres.totalCount === 1 ? "" : "s"}`
|
|
227
|
+
: "";
|
|
228
|
+
const lines = [
|
|
229
|
+
`**Model:** \`${displayName}\`${scope}${counts}`,
|
|
230
|
+
...pres.modelDetails,
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
return { content: lines.join("\n"), components };
|
|
234
|
+
}
|
|
@@ -303,7 +303,9 @@ export interface PlanWindowDisplay {
|
|
|
303
303
|
export interface PlanDisplay {
|
|
304
304
|
plan: string | undefined;
|
|
305
305
|
windows: PlanWindowDisplay[];
|
|
306
|
-
/** Set only
|
|
306
|
+
/** Set only when the account still has one-shot rate-limit resets left. */
|
|
307
|
+
resetsAvailable: number | undefined;
|
|
308
|
+
/** Set only when the figures have aged, e.g. "12m ago". */
|
|
307
309
|
ageLabel: string | undefined;
|
|
308
310
|
}
|
|
309
311
|
|
|
@@ -330,6 +332,7 @@ export function buildPlanDisplay(
|
|
|
330
332
|
const age = Date.now() - usage.fetchedAt;
|
|
331
333
|
return {
|
|
332
334
|
plan: usage.plan,
|
|
335
|
+
resetsAvailable: usage.resetsAvailable,
|
|
333
336
|
ageLabel:
|
|
334
337
|
age > PLAN_STALE_AFTER_MS
|
|
335
338
|
? formatRelativeAge(usage.fetchedAt)
|
|
@@ -222,6 +222,12 @@ export function renderUsageMessage(entries: BackendUsageEntry[]): string {
|
|
|
222
222
|
const age = entry.plan.ageLabel ? ` <i>(${entry.plan.ageLabel})</i>` : "";
|
|
223
223
|
const plan = entry.plan.plan ? ` · ${escapeHtml(entry.plan.plan)}` : "";
|
|
224
224
|
lines.push("", `<b>${name}</b>${plan}${age}`);
|
|
225
|
+
if (entry.plan.resetsAvailable) {
|
|
226
|
+
const n = entry.plan.resetsAvailable;
|
|
227
|
+
lines.push(
|
|
228
|
+
` • You have <b>${n}</b> usage limit reset${n === 1 ? "" : "s"} available`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
225
231
|
for (const w of entry.plan.windows) {
|
|
226
232
|
const reset = w.resetLabel ? ` reset ${w.resetLabel}` : "";
|
|
227
233
|
lines.push(
|