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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.17.0",
3
+ "version": "3.19.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -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: async (m, options) => {
73
- // Kilo's internal helper returns the bare picker shape;
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: async (m, options) => {
70
- const inner = await ocGetSettingsPresentation(
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
- /** How many quick-pick buttons to offer (Telegram 4, Discord 24). */
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
- callbackPrefix?: string,
73
- ): Promise<{ modelButtons: Array<ModelButton>; modelDetails: Array<string> }>;
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
- callbackPrefix = "settings:model:",
144
- ): Promise<{
145
- modelButtons: Array<ModelButton>;
146
- modelDetails: Array<string>;
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 modelButtons: Array<ModelButton> = picks.map((m) => {
153
- const btnLabel =
154
- m.id.length <= 20 ? m.id : m.name.length <= 20 ? m.name : m.id;
155
- const txt = m.free ? `${btnLabel} ★` : btnLabel;
156
- const sel =
157
- current && m.id === current.id && m.providerID === current.providerID;
158
- return {
159
- text: sel ? `✓ ${txt}` : txt,
160
- callback_data: `${callbackPrefix}${m.id}`,
161
- };
162
- });
163
- modelButtons.push({
164
- text: "Reset",
165
- callback_data: `${callbackPrefix}reset`,
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 { modelButtons, modelDetails: details };
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
- callbackPrefix?: string,
40
- ): Promise<{ modelButtons: ModelButton[]; modelDetails: string[] }>;
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
- callbackPrefix?: string,
49
- ): Promise<{ modelButtons: ModelButton[]; modelDetails: string[] }>;
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, callbackPrefix = "settings:model:") {
122
- return deps.getSettingsPresentation(activeModel, callbackPrefix);
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 menu = new StringSelectMenuBuilder()
330
- .setCustomId("model:select")
331
- .setPlaceholder("Pick a model")
332
- .addOptions(
333
- pres.modelButtons.slice(0, 25).map((b) => ({
334
- label: safeSlice(b.text.replace(/^✓ /, ""), 100),
335
- value: safeSlice(b.callback_data.replace(/^model:/, ""), 100),
336
- default: b.text.startsWith("✓"),
337
- })),
338
- );
339
- const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
340
- menu,
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: [`**Model:** \`${displayName}\``, ...pres.modelDetails].join(
345
- "\n",
346
- ),
347
- components: [row],
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
- // Build select menu from the top 25 models. Discord caps select-menu
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: lines.join("\n"),
123
- components: [row],
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 once the figures have aged, e.g. "12m ago". */
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(