@bubblebrain-ai/bubble 0.0.35 → 0.0.36

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.
Files changed (143) hide show
  1. package/README.md +27 -7
  2. package/dist/agent/categories.d.ts +66 -2
  3. package/dist/agent/categories.js +120 -19
  4. package/dist/agent/discovery-barrier.js +1 -1
  5. package/dist/agent/profiles.d.ts +1 -0
  6. package/dist/agent/profiles.js +25 -11
  7. package/dist/agent/routing-catalog.d.ts +113 -0
  8. package/dist/agent/routing-catalog.js +240 -0
  9. package/dist/agent/workflow/control.d.ts +14 -0
  10. package/dist/agent/workflow/control.js +30 -9
  11. package/dist/agent.d.ts +75 -5
  12. package/dist/agent.js +313 -61
  13. package/dist/approval/controller.js +28 -0
  14. package/dist/approval/tool-helper.js +2 -0
  15. package/dist/approval/types.d.ts +21 -1
  16. package/dist/cli.d.ts +1 -1
  17. package/dist/cli.js +2 -1
  18. package/dist/config.d.ts +4 -0
  19. package/dist/config.js +4 -0
  20. package/dist/context/compact-llm.d.ts +2 -2
  21. package/dist/external-runtime/grok-acp.d.ts +56 -0
  22. package/dist/external-runtime/grok-acp.js +386 -0
  23. package/dist/external-runtime/grok-binary.d.ts +25 -0
  24. package/dist/external-runtime/grok-binary.js +108 -0
  25. package/dist/external-runtime/grok-errors.d.ts +7 -0
  26. package/dist/external-runtime/grok-errors.js +15 -0
  27. package/dist/external-runtime/grok-input-policy.d.ts +58 -0
  28. package/dist/external-runtime/grok-input-policy.js +52 -0
  29. package/dist/external-runtime/grok-ndjson.d.ts +14 -0
  30. package/dist/external-runtime/grok-ndjson.js +193 -0
  31. package/dist/external-runtime/grok-network.d.ts +15 -0
  32. package/dist/external-runtime/grok-network.js +81 -0
  33. package/dist/external-runtime/grok-preflight.d.ts +28 -0
  34. package/dist/external-runtime/grok-preflight.js +194 -0
  35. package/dist/external-runtime/grok-process.d.ts +26 -0
  36. package/dist/external-runtime/grok-process.js +184 -0
  37. package/dist/external-runtime/grok-profile.d.ts +32 -0
  38. package/dist/external-runtime/grok-profile.js +419 -0
  39. package/dist/external-runtime/grok-provider.d.ts +31 -0
  40. package/dist/external-runtime/grok-provider.js +41 -0
  41. package/dist/external-runtime/grok-runtime.d.ts +91 -0
  42. package/dist/external-runtime/grok-runtime.js +882 -0
  43. package/dist/external-runtime/index.d.ts +13 -0
  44. package/dist/external-runtime/index.js +13 -0
  45. package/dist/external-runtime/session-policy.d.ts +11 -0
  46. package/dist/external-runtime/session-policy.js +30 -0
  47. package/dist/external-runtime/types.d.ts +65 -0
  48. package/dist/external-runtime/types.js +1 -0
  49. package/dist/feishu/agent-host/approval-card.js +21 -0
  50. package/dist/feishu/agent-host/run-driver.js +19 -3
  51. package/dist/feishu/serve.js +1 -0
  52. package/dist/goal/tools.d.ts +6 -3
  53. package/dist/goal/tools.js +7 -19
  54. package/dist/main.js +105 -27
  55. package/dist/memory/db.d.ts +2 -0
  56. package/dist/memory/db.js +10 -0
  57. package/dist/memory/index.d.ts +1 -0
  58. package/dist/memory/index.js +1 -0
  59. package/dist/memory/phase1.js +14 -2
  60. package/dist/memory/phase2.js +21 -2
  61. package/dist/memory/prompts.js +1 -1
  62. package/dist/memory/session-policy.d.ts +25 -0
  63. package/dist/memory/session-policy.js +55 -0
  64. package/dist/memory/storage.d.ts +1 -0
  65. package/dist/memory/storage.js +13 -0
  66. package/dist/model-catalog.d.ts +18 -0
  67. package/dist/model-catalog.js +87 -41
  68. package/dist/model-config.d.ts +3 -1
  69. package/dist/model-config.js +11 -0
  70. package/dist/oauth/grok.d.ts +25 -0
  71. package/dist/oauth/grok.js +218 -0
  72. package/dist/oauth/index.d.ts +1 -0
  73. package/dist/oauth/index.js +1 -0
  74. package/dist/oauth/storage.d.ts +9 -0
  75. package/dist/oauth/storage.js +25 -0
  76. package/dist/orchestrator/default-hooks.js +17 -7
  77. package/dist/prompt/compose.d.ts +3 -0
  78. package/dist/prompt/compose.js +3 -0
  79. package/dist/prompt/delegation.js +22 -3
  80. package/dist/prompt/environment.js +0 -4
  81. package/dist/prompt/reminders.d.ts +0 -1
  82. package/dist/prompt/reminders.js +1 -11
  83. package/dist/prompt/routing.d.ts +14 -0
  84. package/dist/prompt/routing.js +71 -0
  85. package/dist/prompt/runtime.js +1 -1
  86. package/dist/prompt/task-reminders.d.ts +19 -0
  87. package/dist/prompt/task-reminders.js +49 -0
  88. package/dist/provider-ai-sdk.js +2 -2
  89. package/dist/provider-grok.d.ts +26 -0
  90. package/dist/provider-grok.js +59 -0
  91. package/dist/provider-openai-codex.d.ts +17 -1
  92. package/dist/provider-openai-codex.js +101 -29
  93. package/dist/provider-registry.d.ts +72 -0
  94. package/dist/provider-registry.js +336 -59
  95. package/dist/provider-transform.d.ts +1 -1
  96. package/dist/provider-transform.js +28 -5
  97. package/dist/provider.d.ts +3 -0
  98. package/dist/provider.js +14 -0
  99. package/dist/session-types.d.ts +19 -1
  100. package/dist/session.d.ts +1 -0
  101. package/dist/session.js +4 -0
  102. package/dist/slash-commands/commands.js +224 -14
  103. package/dist/slash-commands/types.d.ts +14 -1
  104. package/dist/system-prompt.d.ts +2 -0
  105. package/dist/tools/agent-lifecycle.js +9 -11
  106. package/dist/tools/bash.js +1 -1
  107. package/dist/tools/child-tools.js +2 -0
  108. package/dist/tools/index.d.ts +2 -2
  109. package/dist/tools/index.js +7 -5
  110. package/dist/tools/lsp.js +1 -0
  111. package/dist/tools/memory.d.ts +7 -2
  112. package/dist/tools/memory.js +28 -43
  113. package/dist/tools/server.js +4 -0
  114. package/dist/tools/tool-search.d.ts +1 -1
  115. package/dist/tools/tool-search.js +23 -3
  116. package/dist/tui/model-switch.d.ts +7 -0
  117. package/dist/tui/model-switch.js +15 -5
  118. package/dist/tui-ink/app.d.ts +3 -1
  119. package/dist/tui-ink/app.js +648 -232
  120. package/dist/tui-ink/approval/approval-dialog.js +36 -0
  121. package/dist/tui-ink/composer-buffer.d.ts +55 -0
  122. package/dist/tui-ink/composer-buffer.js +317 -0
  123. package/dist/tui-ink/footer.d.ts +1 -0
  124. package/dist/tui-ink/footer.js +7 -2
  125. package/dist/tui-ink/input-box.d.ts +35 -1
  126. package/dist/tui-ink/input-box.js +215 -122
  127. package/dist/tui-ink/model-picker.d.ts +61 -4
  128. package/dist/tui-ink/model-picker.js +453 -123
  129. package/dist/tui-ink/paste-operation-tracker.d.ts +13 -0
  130. package/dist/tui-ink/paste-operation-tracker.js +25 -0
  131. package/dist/tui-ink/run.d.ts +3 -0
  132. package/dist/tui-ink/run.js +1 -1
  133. package/dist/tui-ink/subagent-view.d.ts +31 -0
  134. package/dist/tui-ink/subagent-view.js +82 -1
  135. package/dist/types.d.ts +5 -5
  136. package/dist/types.js +1 -1
  137. package/dist/variant/thinking-level.d.ts +2 -2
  138. package/dist/variant/thinking-level.js +2 -1
  139. package/dist/variant/variant-resolver.d.ts +9 -0
  140. package/dist/variant/variant-resolver.js +17 -0
  141. package/package.json +2 -1
  142. package/dist/tools/task.d.ts +0 -2
  143. package/dist/tools/task.js +0 -58
@@ -1,9 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useState, useEffect, useMemo } from "react";
2
+ import { useState, useEffect, useMemo, useRef } from "react";
3
3
  import { Box, Text, useInput, usePaste, useStdout } from "ink";
4
4
  import { isKeyReleaseEvent } from "./key-events.js";
5
5
  import { useTheme } from "./theme.js";
6
- import { encodeModel, decodeModel, displayModel, isUserVisibleProvider } from "../provider-registry.js";
6
+ import { encodeModel, decodeModel, displayModel, isUserVisibleProvider, } from "../provider-registry.js";
7
7
  import { listBuiltinModels } from "../model-catalog.js";
8
8
  import { padVisual, truncateVisual } from "../text-display.js";
9
9
  import { hasTerminalMouseSequence } from "./terminal-mouse.js";
@@ -53,7 +53,8 @@ export function formatSkillPickerRow(skill, options) {
53
53
  return padVisual(truncateVisual(row, width), width);
54
54
  }
55
55
  export const MODEL_PICKER_MAX_BODY_ROWS = 10;
56
- export const MODEL_PICKER_CHROME_ROWS = 13;
56
+ export const MODEL_PICKER_CHROME_ROWS = 14;
57
+ export const MODEL_PICKER_RECENT_FAMILY_LIMIT = 3;
57
58
  export function modelPickerBodyRows(termHeight) {
58
59
  const rows = Number.isFinite(termHeight) ? Math.floor(termHeight) : 24;
59
60
  return Math.max(1, Math.min(MODEL_PICKER_MAX_BODY_ROWS, rows - MODEL_PICKER_CHROME_ROWS));
@@ -78,24 +79,98 @@ export function padPickerRows(rows, bodyRows, width) {
78
79
  }
79
80
  return padded;
80
81
  }
82
+ export function modelPickerRecentKey(recent) {
83
+ return recent.join("\u0000");
84
+ }
85
+ /**
86
+ * Returns a provider-scoped family key for model ordering.
87
+ *
88
+ * Keep this deliberately conservative: suffixes such as `mini`, `codex`,
89
+ * `spark`, or `highspeed` often describe distinct products rather than
90
+ * interchangeable siblings. The Codex Sol/Terra/Luna trio is the one family
91
+ * whose relationship is explicitly declared by the provider catalog.
92
+ */
93
+ export function modelPickerFamilyKey(provider, modelId) {
94
+ const isCodexOAuth = provider.id === "openai-codex"
95
+ || (provider.id === "openai" && provider.authType === "oauth");
96
+ if (isCodexOAuth) {
97
+ const match = /^(gpt-\d+(?:\.\d+)+)-(sol|terra|luna)$/i.exec(modelId);
98
+ if (match)
99
+ return `${provider.id}:${match[1].toLowerCase()}:sol-terra-luna`;
100
+ }
101
+ return `${provider.id}:${modelId}`;
102
+ }
103
+ export function buildModelPickerDisplayItems(options) {
104
+ const items = [];
105
+ let previousGroup;
106
+ let headerIndex = 0;
107
+ for (const option of options) {
108
+ if (option.group !== previousGroup) {
109
+ previousGroup = option.group;
110
+ items.push({
111
+ kind: "header",
112
+ key: `header:${headerIndex++}:${option.group}`,
113
+ label: option.group,
114
+ });
115
+ }
116
+ items.push({ kind: "model", key: option.id, option });
117
+ }
118
+ return items;
119
+ }
120
+ export function resolveModelPickerDisplayIndex(items, selectedId) {
121
+ if (selectedId) {
122
+ const selectedIndex = items.findIndex((item) => item.kind === "model" && item.option.id === selectedId);
123
+ if (selectedIndex >= 0)
124
+ return selectedIndex;
125
+ }
126
+ const firstModelIndex = items.findIndex((item) => item.kind === "model");
127
+ return firstModelIndex >= 0 ? firstModelIndex : 0;
128
+ }
129
+ export function filterAndRankModelPickerOptions(options, query) {
130
+ const normalized = query.trim().toLowerCase();
131
+ if (!normalized)
132
+ return [...options];
133
+ return options
134
+ .map((option, index) => {
135
+ const { modelId } = decodeModel(option.id);
136
+ const fields = [option.label, modelId, option.id, option.providerBadge]
137
+ .map((value) => value.toLowerCase());
138
+ const rank = fields.some((value) => value === normalized)
139
+ ? 0
140
+ : fields.some((value) => value.startsWith(normalized))
141
+ ? 1
142
+ : fields.some((value) => value.includes(normalized))
143
+ ? 2
144
+ : Number.POSITIVE_INFINITY;
145
+ return { option, index, rank };
146
+ })
147
+ .filter((entry) => Number.isFinite(entry.rank))
148
+ .sort((left, right) => left.rank - right.rank || left.index - right.index)
149
+ .map((entry) => entry.option);
150
+ }
81
151
  function isThinkingToggleOption(option) {
82
152
  const decoded = decodeModel(option.id);
83
153
  return isThinkingToggleModel(decoded.providerId || option.providerBadge, decoded.modelId);
84
154
  }
85
155
  export function formatReasoningLevelsLabel(levels, asToggle = false) {
156
+ if (levels.length === 0)
157
+ return "checking";
86
158
  if (isThinkingOnlyLevels(levels))
87
159
  return "on";
88
160
  if (asToggle)
89
161
  return "thinking on/off";
90
- const normalized = levels.length > 0 ? levels : ["off"];
91
- return normalized.join("/");
162
+ return levels.join("/");
92
163
  }
93
164
  export function formatModelPickerRow(option, options) {
94
165
  const width = Math.max(24, options.width);
95
166
  const marker = options.selected ? "> " : " ";
96
167
  const label = option.label.replace(/\s+/g, " ").trim();
97
168
  const provider = option.providerBadge.replace(/\s+/g, " ").trim();
98
- const effort = formatReasoningLevelsLabel(option.reasoningLevels, isThinkingToggleOption(option));
169
+ const effort = option.pending
170
+ ? "checking"
171
+ : (option.available === false || option.reasoningLevels.length === 0)
172
+ ? "unavailable"
173
+ : formatReasoningLevelsLabel(option.reasoningLevels, isThinkingToggleOption(option));
99
174
  const current = options.current ? " ●" : "";
100
175
  const providerWidth = Math.max(6, Math.min(16, Math.floor(width * 0.18)));
101
176
  const effortWidth = Math.max(12, Math.min(30, Math.floor(width * 0.32)));
@@ -127,6 +202,14 @@ export function formatNoModelResultsRow(query, width) {
127
202
  return padVisual(truncateVisual(row, rowWidth), rowWidth);
128
203
  }
129
204
  export function preferredEffortIndex(option, currentThinkingLevel) {
205
+ const currentIndex = option.reasoningLevels.indexOf(currentThinkingLevel);
206
+ if (currentIndex >= 0)
207
+ return currentIndex;
208
+ if (option.defaultReasoningLevel) {
209
+ const defaultIndex = option.reasoningLevels.indexOf(option.defaultReasoningLevel);
210
+ if (defaultIndex >= 0)
211
+ return defaultIndex;
212
+ }
130
213
  const preferred = normalizeThinkingLevel(currentThinkingLevel, option.reasoningLevels);
131
214
  const index = option.reasoningLevels.indexOf(preferred);
132
215
  return index >= 0 ? index : 0;
@@ -134,6 +217,46 @@ export function preferredEffortIndex(option, currentThinkingLevel) {
134
217
  export function shouldOpenEffortPicker(option) {
135
218
  return option.reasoningLevels.length > 1;
136
219
  }
220
+ export function isModelOptionSelectable(option) {
221
+ return option.available && !option.pending && option.reasoningLevels.length > 0;
222
+ }
223
+ export function resolveSelectedModelId(options, selectedId) {
224
+ if (selectedId) {
225
+ const selected = options.find((option) => option.id === selectedId);
226
+ if (selected && isModelOptionSelectable(selected))
227
+ return selected.id;
228
+ }
229
+ return options.find(isModelOptionSelectable)?.id;
230
+ }
231
+ export function moveModelSelection(options, selectedId, direction) {
232
+ const selectableIndexes = options
233
+ .map((option, index) => isModelOptionSelectable(option) ? index : -1)
234
+ .filter((index) => index >= 0);
235
+ if (selectableIndexes.length === 0)
236
+ return undefined;
237
+ const currentIndex = options.findIndex((option) => option.id === selectedId);
238
+ if (currentIndex < 0) {
239
+ return options[direction > 0 ? selectableIndexes[0] : selectableIndexes[selectableIndexes.length - 1]]?.id;
240
+ }
241
+ const target = direction > 0
242
+ ? selectableIndexes.find((index) => index > currentIndex)
243
+ : [...selectableIndexes].reverse().find((index) => index < currentIndex);
244
+ return options[target ?? currentIndex]?.id ?? selectedId;
245
+ }
246
+ export function reconcileModelPickerPhase(phase, options, currentThinkingLevel) {
247
+ if (phase.kind !== "effort")
248
+ return phase;
249
+ const latest = options.find((option) => option.id === phase.model.id);
250
+ if (!latest || !isModelOptionSelectable(latest))
251
+ return { kind: "model" };
252
+ const selectedLevel = phase.model.reasoningLevels[phase.selectedIndex];
253
+ const selectedIndex = selectedLevel && latest.reasoningLevels.includes(selectedLevel)
254
+ ? latest.reasoningLevels.indexOf(selectedLevel)
255
+ : preferredEffortIndex(latest, currentThinkingLevel);
256
+ if (selectedIndex === phase.selectedIndex && sameModelOptionMetadata(phase.model, latest))
257
+ return phase;
258
+ return { kind: "effort", model: latest, selectedIndex };
259
+ }
137
260
  function effortDescription(level, asToggle) {
138
261
  if (asToggle)
139
262
  return level === "off" ? "thinking disabled" : "thinking enabled";
@@ -152,6 +275,8 @@ function effortDescription(level, asToggle) {
152
275
  return "extra high reasoning";
153
276
  case "max":
154
277
  return "maximum provider effort";
278
+ case "ultra":
279
+ return "maximum effort with delegation";
155
280
  default:
156
281
  return "reasoning effort";
157
282
  }
@@ -169,81 +294,97 @@ export function ModelPicker({ registry, current, currentThinkingLevel, recent, o
169
294
  const terminalColumns = stdout?.columns || 80;
170
295
  const bodyRows = modelPickerBodyRows(termHeight);
171
296
  const rowWidth = Math.max(36, Math.min(110, terminalColumns - 6));
172
- const [rawOptions, setRawOptions] = useState(() => buildLocalModelOptions(registry, current, recent));
297
+ const recentKey = modelPickerRecentKey(recent);
298
+ const enabledSnapshot = registry.getEnabled().filter((provider) => isUserVisibleProvider(provider.id));
299
+ const providerKey = enabledSnapshot
300
+ .map((provider) => [
301
+ provider.id,
302
+ provider.name,
303
+ provider.baseURL,
304
+ provider.authType ?? "",
305
+ provider.protocol ?? "",
306
+ ].join("\u0000"))
307
+ .join("\u0001");
308
+ const enabledProviders = useMemo(() => registry.getEnabled().filter((provider) => isUserVisibleProvider(provider.id)), [registry, providerKey]);
309
+ const localModelsByProvider = useMemo(() => new Map(enabledProviders.map((provider) => [
310
+ provider.id,
311
+ localModelsForProvider(registry, provider),
312
+ ])), [registry, providerKey]);
313
+ const [discoveries, setDiscoveries] = useState(() => new Map());
314
+ const [refreshGeneration, setRefreshGeneration] = useState(0);
315
+ const requestGenerationRef = useRef(0);
316
+ const rawOptions = useMemo(() => buildMergedModelOptions({
317
+ providers: enabledProviders,
318
+ localModelsByProvider,
319
+ discoveries,
320
+ current,
321
+ recent,
322
+ }), [enabledProviders, localModelsByProvider, discoveries, current, recentKey]);
173
323
  const [query, setQuery] = useState("");
174
- const [selectedIndex, setSelectedIndex] = useState(() => preferredModelIndex(buildLocalModelOptions(registry, current, recent), current));
324
+ const [selectedId, setSelectedId] = useState(() => resolveSelectedModelId(buildLocalModelOptions(registry, current, recent), current));
175
325
  const [phase, setPhase] = useState({ kind: "model" });
176
326
  useEffect(() => {
177
- let cancelled = false;
178
- const localOptions = buildLocalModelOptions(registry, current, recent);
179
- setRawOptions(localOptions);
180
- setSelectedIndex(preferredModelIndex(localOptions, current));
181
- async function refreshRemote() {
182
- const enabled = registry.getEnabled();
183
- const opts = [];
184
- const seen = new Set();
185
- // Recent first
186
- for (const m of recent.slice(0, 5)) {
187
- const { providerId } = decodeModel(m);
188
- const provider = enabled.find((p) => p.id === providerId);
189
- appendModelOption(opts, seen, {
190
- id: m,
191
- label: displayModel(m),
192
- group: "Recent",
193
- providerBadge: provider?.name || providerId || "",
327
+ const generation = ++requestGenerationRef.current;
328
+ setDiscoveries((previous) => {
329
+ const next = new Map();
330
+ for (const provider of enabledProviders) {
331
+ const prior = previous.get(provider.id);
332
+ next.set(provider.id, {
333
+ status: "pending",
334
+ models: prior?.models ?? [],
335
+ source: prior?.source,
336
+ authoritative: prior?.authoritative ?? false,
194
337
  });
195
338
  }
196
- const visibleProviders = enabled.filter((item) => isUserVisibleProvider(item.id));
197
- const discovered = await Promise.all(visibleProviders.map(async (provider) => {
198
- try {
199
- return { provider, models: await registry.listModels(provider) };
200
- }
201
- catch {
202
- return { provider, models: localModelsForProvider(registry, provider) };
203
- }
204
- }));
205
- for (const { provider, models } of discovered) {
206
- for (const m of models) {
207
- appendModelOption(opts, seen, {
208
- id: encodeModel(m.providerId, m.id),
209
- label: m.name,
210
- group: provider.name,
211
- providerBadge: provider.name,
212
- });
213
- }
214
- }
215
- if (current && !seen.has(current)) {
216
- const { providerId } = decodeModel(current);
217
- const provider = enabled.find((p) => p.id === providerId);
218
- opts.unshift({
219
- id: current,
220
- label: displayModel(current),
221
- group: "Current",
222
- providerBadge: provider?.name || providerId || "",
223
- reasoningLevels: reasoningLevelsForModel(current),
339
+ return next;
340
+ });
341
+ for (const provider of enabledProviders) {
342
+ void discoverModelsForPicker(registry, provider, refreshGeneration > 0)
343
+ .then((result) => {
344
+ if (requestGenerationRef.current !== generation)
345
+ return;
346
+ setDiscoveries((previous) => {
347
+ const next = new Map(previous);
348
+ next.set(provider.id, { status: "complete", ...result });
349
+ return next;
224
350
  });
225
- }
226
- if (!cancelled) {
227
- setRawOptions(opts);
228
- setSelectedIndex((index) => {
229
- const currentIndex = preferredModelIndex(opts, current);
230
- return index === preferredModelIndex(localOptions, current)
231
- ? currentIndex
232
- : clampPickerIndex(index, opts.length);
351
+ })
352
+ .catch((error) => {
353
+ if (requestGenerationRef.current !== generation)
354
+ return;
355
+ setDiscoveries((previous) => {
356
+ const next = new Map(previous);
357
+ next.set(provider.id, {
358
+ status: "complete",
359
+ models: localModelsByProvider.get(provider.id) ?? [],
360
+ source: "fallback",
361
+ authoritative: false,
362
+ error: error instanceof Error ? error.message : String(error),
363
+ });
364
+ return next;
233
365
  });
234
- }
366
+ });
235
367
  }
236
- void refreshRemote();
237
368
  return () => {
238
- cancelled = true;
369
+ if (requestGenerationRef.current === generation)
370
+ requestGenerationRef.current++;
239
371
  };
240
- }, [registry, current, recent]);
372
+ }, [registry, providerKey, refreshGeneration]);
241
373
  const options = useMemo(() => {
242
- if (!query.trim())
243
- return rawOptions;
244
- const q = query.toLowerCase();
245
- return rawOptions.filter((opt) => opt.label.toLowerCase().includes(q) || opt.providerBadge.toLowerCase().includes(q));
374
+ return filterAndRankModelPickerOptions(rawOptions, query);
246
375
  }, [rawOptions, query]);
376
+ const resolvedSelectedId = resolveSelectedModelId(options, selectedId);
377
+ const selectedIndex = resolvedSelectedId
378
+ ? options.findIndex((option) => option.id === resolvedSelectedId)
379
+ : -1;
380
+ useEffect(() => {
381
+ if (selectedId !== resolvedSelectedId)
382
+ setSelectedId(resolvedSelectedId);
383
+ }, [selectedId, resolvedSelectedId]);
384
+ useEffect(() => {
385
+ setPhase((currentPhase) => reconcileModelPickerPhase(currentPhase, rawOptions, currentThinkingLevel));
386
+ }, [rawOptions, currentThinkingLevel]);
387
+ const discoveryStatus = formatModelDiscoveryStatus(enabledProviders, discoveries);
247
388
  useInput((input, key) => {
248
389
  if (isKeyReleaseEvent(key))
249
390
  return;
@@ -255,7 +396,9 @@ export function ModelPicker({ registry, current, currentThinkingLevel, recent, o
255
396
  return;
256
397
  }
257
398
  if (action === "enter") {
258
- onSelect(phase.model.id, levels[clampPickerIndex(phase.selectedIndex, levels.length)] ?? "off");
399
+ const level = levels[clampPickerIndex(phase.selectedIndex, levels.length)];
400
+ if (level)
401
+ onSelect(phase.model.id, level);
259
402
  return;
260
403
  }
261
404
  if (action === "up") {
@@ -272,13 +415,17 @@ export function ModelPicker({ registry, current, currentThinkingLevel, recent, o
272
415
  }
273
416
  return;
274
417
  }
418
+ if (key.ctrl && (input.toLowerCase() === "r" || input === "\x12")) {
419
+ setRefreshGeneration((generation) => generation + 1);
420
+ return;
421
+ }
275
422
  if (action === "escape") {
276
423
  onCancel();
277
424
  return;
278
425
  }
279
426
  if (action === "enter") {
280
- const opt = options[clampPickerIndex(selectedIndex, options.length)];
281
- if (opt) {
427
+ const opt = selectedIndex >= 0 ? options[selectedIndex] : undefined;
428
+ if (opt && isModelOptionSelectable(opt)) {
282
429
  if (shouldOpenEffortPicker(opt)) {
283
430
  setPhase({
284
431
  kind: "effort",
@@ -287,23 +434,25 @@ export function ModelPicker({ registry, current, currentThinkingLevel, recent, o
287
434
  });
288
435
  }
289
436
  else {
290
- onSelect(opt.id, opt.reasoningLevels[0] ?? "off");
437
+ const level = opt.reasoningLevels[0];
438
+ if (level)
439
+ onSelect(opt.id, level);
291
440
  }
292
441
  }
293
442
  return;
294
443
  }
295
444
  if (action === "up") {
296
- setSelectedIndex((i) => clampPickerIndex(i - 1, options.length));
445
+ setSelectedId((id) => moveModelSelection(options, id, -1));
297
446
  return;
298
447
  }
299
448
  if (action === "down") {
300
- setSelectedIndex((i) => clampPickerIndex(i + 1, options.length));
449
+ setSelectedId((id) => moveModelSelection(options, id, 1));
301
450
  return;
302
451
  }
303
452
  if (action === "backspace" || action === "delete") {
304
453
  setQuery((q) => {
305
454
  const next = q.slice(0, -1);
306
- setSelectedIndex(0);
455
+ setSelectedId(undefined);
307
456
  return next;
308
457
  });
309
458
  return;
@@ -311,47 +460,60 @@ export function ModelPicker({ registry, current, currentThinkingLevel, recent, o
311
460
  if (isPrintablePickerInput(input) && !key.ctrl && !key.meta) {
312
461
  setQuery((q) => {
313
462
  const next = q + input;
314
- setSelectedIndex(0);
463
+ setSelectedId(undefined);
315
464
  return next;
316
465
  });
317
466
  return;
318
467
  }
319
468
  });
320
- const safeSelectedIndex = clampPickerIndex(selectedIndex, options.length);
321
- const start = pickerWindowStart(safeSelectedIndex, options.length, bodyRows);
322
- const visible = options.slice(start, start + bodyRows);
469
+ const displayItems = buildModelPickerDisplayItems(options);
470
+ const safeSelectedDisplayIndex = resolveModelPickerDisplayIndex(displayItems, resolvedSelectedId);
471
+ const start = pickerWindowStart(safeSelectedDisplayIndex, displayItems.length, bodyRows);
472
+ const visible = displayItems.slice(start, start + bodyRows);
323
473
  const rawModelRows = options.length === 0
324
474
  ? [{
325
475
  key: "no-results",
326
476
  row: formatNoModelResultsRow(query, rowWidth),
327
477
  selected: false,
478
+ header: false,
328
479
  }]
329
- : visible.map((opt, i) => {
330
- const actualIndex = start + i;
331
- const isSelected = actualIndex === safeSelectedIndex;
480
+ : visible.map((item) => {
481
+ if (item.kind === "header") {
482
+ return {
483
+ key: item.key,
484
+ row: padVisual(truncateVisual(` ${item.label}`, rowWidth), rowWidth),
485
+ selected: false,
486
+ header: true,
487
+ };
488
+ }
489
+ const isSelected = item.option.id === resolvedSelectedId;
332
490
  return {
333
- key: opt.id,
334
- row: formatModelPickerRow(opt, {
491
+ key: item.key,
492
+ row: formatModelPickerRow(item.option, {
335
493
  selected: isSelected,
336
- current: opt.id === current,
494
+ current: item.option.id === current,
337
495
  width: rowWidth,
338
496
  }),
339
497
  selected: isSelected,
498
+ header: false,
340
499
  };
341
500
  });
342
501
  const modelRows = padPickerRows(rawModelRows.map((row) => row.row), bodyRows, rowWidth).map((row, index) => ({
343
502
  key: rawModelRows[index]?.key ?? `blank-${index}`,
344
503
  row,
345
504
  selected: rawModelRows[index]?.selected ?? false,
505
+ header: rawModelRows[index]?.header ?? false,
346
506
  }));
347
- const highlightedOption = options[clampPickerIndex(selectedIndex, options.length)];
348
- const enterAction = highlightedOption && shouldOpenEffortPicker(highlightedOption)
349
- ? (isThinkingToggleOption(highlightedOption) ? "choose mode" : "choose effort")
350
- : "select";
507
+ const highlightedOption = selectedIndex >= 0 ? options[selectedIndex] : undefined;
508
+ const enterAction = highlightedOption
509
+ ? (shouldOpenEffortPicker(highlightedOption)
510
+ ? (isThinkingToggleOption(highlightedOption) ? "choose mode" : "choose effort")
511
+ : "select")
512
+ : "wait";
351
513
  const effortTitle = phase.kind === "effort" && isThinkingToggleOption(phase.model)
352
514
  ? "Select Thinking Mode"
353
515
  : "Select Reasoning Effort";
354
- return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, children: [_jsx(Text, { bold: true, color: theme.accent, children: phase.kind === "effort" ? effortTitle : "Select Model" }), phase.kind === "effort" ? (_jsx(EffortPickerView, { model: phase.model, selectedIndex: phase.selectedIndex, bodyRows: bodyRows, rowWidth: rowWidth })) : (_jsxs(_Fragment, { children: [_jsx(SearchField, { query: query, placeholder: "Type to search models...", width: rowWidth }), _jsxs(Text, { color: theme.muted, children: ["\u2191/\u2193 navigate \u00B7 Enter ", enterAction, " \u00B7 Esc cancel \u00B7 Backspace clear"] })] })), phase.kind === "model" && _jsx(Box, { flexDirection: "column", height: bodyRows, overflow: "hidden", marginTop: 1, children: modelRows.map(({ key, row, selected }) => (_jsx(Box, { height: 1, overflow: "hidden", children: _jsx(Text, { color: selected ? theme.accent : (key === "no-results" ? theme.muted : undefined), bold: selected, children: row }) }, key))) })] }));
516
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, children: [_jsx(Text, { bold: true, color: theme.accent, children: phase.kind === "effort" ? effortTitle : "Select Model" }), phase.kind === "effort" ? (_jsx(EffortPickerView, { model: phase.model, selectedIndex: phase.selectedIndex, bodyRows: bodyRows, rowWidth: rowWidth })) : (_jsxs(_Fragment, { children: [_jsx(SearchField, { query: query, placeholder: "Type to search models...", width: rowWidth }), _jsxs(Text, { color: theme.muted, children: ["\u2191/\u2193 navigate \u00B7 Enter ", enterAction, " \u00B7 Esc cancel \u00B7 Ctrl+R retry"] }), _jsx(Box, { height: 1, overflow: "hidden", children: _jsx(Text, { color: discoveryStatus.hasError ? theme.warning : theme.muted, children: discoveryStatus.text || " " }) })] })), phase.kind === "model" && _jsx(Box, { flexDirection: "column", height: bodyRows, overflow: "hidden", marginTop: 1, children: modelRows.map(({ key, row, selected, header }) => (_jsx(Box, { height: 1, overflow: "hidden", children: _jsx(Text, { color: selected ? theme.accent : (header || key === "no-results" ? theme.muted : undefined), bold: selected || header, children: row }) }, key))) })] }));
355
517
  }
356
518
  function EffortPickerView({ model, selectedIndex, bodyRows, rowWidth, }) {
357
519
  const theme = useTheme();
@@ -388,40 +550,124 @@ function SearchField({ query, placeholder, width }) {
388
550
  return (_jsxs(Box, { height: 1, overflow: "hidden", marginTop: 1, children: [_jsx(Text, { color: theme.accent, children: "❯ " }), _jsx(Text, { children: visibleQuery }), _jsx(Text, { color: theme.accent, inverse: cursorVisible, children: " " }), visiblePlaceholder && _jsx(Text, { color: theme.muted, dimColor: true, children: visiblePlaceholder })] }));
389
551
  }
390
552
  export function buildLocalModelOptions(registry, current, recent) {
391
- const enabled = registry.getEnabled();
553
+ const providers = registry.getEnabled().filter((provider) => isUserVisibleProvider(provider.id));
554
+ const localModelsByProvider = new Map(providers.map((provider) => [
555
+ provider.id,
556
+ localModelsForProvider(registry, provider),
557
+ ]));
558
+ return buildMergedModelOptions({
559
+ providers,
560
+ localModelsByProvider,
561
+ discoveries: new Map(),
562
+ current,
563
+ recent,
564
+ });
565
+ }
566
+ export function buildMergedModelOptions(input) {
392
567
  const opts = [];
393
568
  const seen = new Set();
394
- for (const model of recent.slice(0, 5)) {
395
- const { providerId } = decodeModel(model);
396
- const provider = enabled.find((item) => item.id === providerId);
397
- appendModelOption(opts, seen, {
398
- id: model,
399
- label: displayModel(model),
400
- group: "Recent",
401
- providerBadge: provider?.name || providerId || "",
402
- });
569
+ const providerById = new Map(input.providers.map((provider) => [provider.id, provider]));
570
+ const effectiveModelsByProvider = new Map(input.providers.map((provider) => {
571
+ const localModels = input.localModelsByProvider.get(provider.id) ?? [];
572
+ const discovery = input.discoveries.get(provider.id);
573
+ return [provider.id, effectiveProviderModels(localModels, discovery)];
574
+ }));
575
+ const promotedFamilies = new Set();
576
+ const promotedRecentProviderIds = [];
577
+ const familyKeyForId = (id) => {
578
+ const { providerId, modelId } = decodeModel(id);
579
+ const provider = providerId ? providerById.get(providerId) : undefined;
580
+ return provider
581
+ ? modelPickerFamilyKey(provider, modelId)
582
+ : `unknown:${id}`;
583
+ };
584
+ const appendPinned = (id, group) => {
585
+ appendModelOption(opts, seen, pinnedModelOption({
586
+ id,
587
+ group,
588
+ providers: input.providers,
589
+ localModelsByProvider: input.localModelsByProvider,
590
+ discoveries: input.discoveries,
591
+ }));
592
+ };
593
+ const appendFamily = (seedId, group) => {
594
+ const { providerId, modelId } = decodeModel(seedId);
595
+ const provider = providerId ? providerById.get(providerId) : undefined;
596
+ if (!provider) {
597
+ appendPinned(seedId, group);
598
+ return;
599
+ }
600
+ const familyKey = modelPickerFamilyKey(provider, modelId);
601
+ const familyModels = (effectiveModelsByProvider.get(provider.id) ?? [])
602
+ .filter((model) => modelPickerFamilyKey(provider, model.id) === familyKey);
603
+ for (const model of familyModels) {
604
+ appendModelOption(opts, seen, {
605
+ ...modelOptionMetadata(encodeModel(provider.id, model.id), model, true),
606
+ group,
607
+ providerBadge: provider.name,
608
+ available: true,
609
+ pending: false,
610
+ });
611
+ }
612
+ // Keep an exact Current/Recent pin visible even when an authoritative
613
+ // catalog no longer contains it. It remains unavailable and never causes
614
+ // local siblings to be reintroduced.
615
+ if (!familyModels.some((model) => model.id === modelId))
616
+ appendPinned(seedId, group);
617
+ };
618
+ if (input.current) {
619
+ appendPinned(input.current, "Current");
620
+ const currentFamilyKey = familyKeyForId(input.current);
621
+ promotedFamilies.add(currentFamilyKey);
622
+ appendFamily(input.current, "Current");
403
623
  }
404
- for (const provider of enabled.filter((item) => isUserVisibleProvider(item.id))) {
405
- for (const model of localModelsForProvider(registry, provider)) {
624
+ let recentFamilyCount = 0;
625
+ for (const recentId of [...new Set(input.recent)]) {
626
+ if (!recentId || recentId === input.current)
627
+ continue;
628
+ const familyKey = familyKeyForId(recentId);
629
+ if (promotedFamilies.has(familyKey)) {
630
+ // A previously promoted family may not contain this exact Recent model
631
+ // in an authoritative catalog. Preserve that pin as unavailable; when
632
+ // it is present, exact-ID deduplication makes this a no-op.
633
+ appendPinned(recentId, "Recent");
634
+ continue;
635
+ }
636
+ promotedFamilies.add(familyKey);
637
+ recentFamilyCount += 1;
638
+ const { providerId } = decodeModel(recentId);
639
+ if (providerId && providerById.has(providerId) && !promotedRecentProviderIds.includes(providerId)) {
640
+ promotedRecentProviderIds.push(providerId);
641
+ }
642
+ appendFamily(recentId, "Recent");
643
+ if (recentFamilyCount >= MODEL_PICKER_RECENT_FAMILY_LIMIT)
644
+ break;
645
+ }
646
+ const orderedProviderIds = [];
647
+ const addProviderId = (providerId) => {
648
+ if (providerId && providerById.has(providerId) && !orderedProviderIds.includes(providerId)) {
649
+ orderedProviderIds.push(providerId);
650
+ }
651
+ };
652
+ addProviderId(decodeModel(input.current).providerId);
653
+ for (const providerId of promotedRecentProviderIds)
654
+ addProviderId(providerId);
655
+ for (const provider of input.providers)
656
+ addProviderId(provider.id);
657
+ for (const providerId of orderedProviderIds) {
658
+ const provider = providerById.get(providerId);
659
+ if (!provider)
660
+ continue;
661
+ for (const model of effectiveModelsByProvider.get(provider.id) ?? []) {
406
662
  appendModelOption(opts, seen, {
407
- id: encodeModel(model.providerId, model.id),
408
- label: model.name,
663
+ ...modelOptionMetadata(encodeModel(provider.id, model.id), model, true),
409
664
  group: provider.name,
410
665
  providerBadge: provider.name,
666
+ available: true,
667
+ pending: false,
411
668
  });
412
669
  }
413
670
  }
414
- if (current && !seen.has(current)) {
415
- const { providerId } = decodeModel(current);
416
- const provider = enabled.find((item) => item.id === providerId);
417
- opts.unshift({
418
- id: current,
419
- label: displayModel(current),
420
- group: "Current",
421
- providerBadge: provider?.name || providerId || "",
422
- reasoningLevels: reasoningLevelsForModel(current),
423
- });
424
- }
425
671
  return opts;
426
672
  }
427
673
  function localModelsForProvider(registry, provider) {
@@ -435,20 +681,104 @@ function localModelsForProvider(registry, provider) {
435
681
  id: model.id,
436
682
  name: model.name,
437
683
  providerId: provider.id,
684
+ reasoningLevels: model.reasoningLevels,
685
+ defaultReasoningLevel: model.defaultReasoningLevel,
686
+ contextWindow: model.contextWindow,
687
+ toolOutputTokenLimit: model.toolOutputTokenLimit,
438
688
  }));
439
689
  }
440
690
  function appendModelOption(options, seen, option) {
441
691
  if (seen.has(option.id))
442
692
  return;
443
693
  seen.add(option.id);
444
- options.push({
445
- ...option,
446
- reasoningLevels: option.reasoningLevels ?? reasoningLevelsForModel(option.id),
447
- });
694
+ options.push(option);
695
+ }
696
+ function pinnedModelOption(input) {
697
+ const { providerId, modelId } = decodeModel(input.id);
698
+ const provider = input.providers.find((item) => item.id === providerId);
699
+ if (!provider) {
700
+ return {
701
+ ...modelOptionMetadata(input.id, undefined, false),
702
+ group: input.group,
703
+ providerBadge: providerId || "",
704
+ available: false,
705
+ pending: false,
706
+ };
707
+ }
708
+ const local = input.localModelsByProvider.get(provider.id) ?? [];
709
+ const discovery = input.discoveries.get(provider.id);
710
+ const discoveredModel = discovery?.models.find((model) => model.id === modelId);
711
+ const localModel = local.find((model) => model.id === modelId);
712
+ const metadata = discoveredModel ?? localModel;
713
+ const hasKnownMetadata = !!metadata;
714
+ let available = hasKnownMetadata;
715
+ let pending = false;
716
+ if (!discovery || discovery.status === "pending") {
717
+ pending = !hasKnownMetadata;
718
+ }
719
+ else if (discovery.authoritative) {
720
+ available = !!discoveredModel;
721
+ }
722
+ return {
723
+ ...modelOptionMetadata(input.id, metadata, hasKnownMetadata),
724
+ group: input.group,
725
+ providerBadge: provider.name,
726
+ available,
727
+ pending,
728
+ };
729
+ }
730
+ function effectiveProviderModels(localModels, discovery) {
731
+ if (!discovery)
732
+ return localModels;
733
+ if (discovery.authoritative)
734
+ return discovery.models;
735
+ return discovery.models.length > 0 ? discovery.models : localModels;
736
+ }
737
+ function modelOptionMetadata(id, rawModel, known) {
738
+ const model = rawModel;
739
+ const reasoningLevels = model?.reasoningLevels
740
+ ? [...model.reasoningLevels]
741
+ : (known ? reasoningLevelsForModel(id) : []);
742
+ const defaultReasoningLevel = model?.defaultReasoningLevel
743
+ && reasoningLevels.includes(model.defaultReasoningLevel)
744
+ ? model.defaultReasoningLevel
745
+ : undefined;
746
+ return {
747
+ id,
748
+ label: model?.name || displayModel(id),
749
+ reasoningLevels,
750
+ defaultReasoningLevel,
751
+ contextWindow: model?.contextWindow,
752
+ toolOutputTokenLimit: model?.toolOutputTokenLimit,
753
+ };
754
+ }
755
+ async function discoverModelsForPicker(registry, provider, forceRefresh) {
756
+ return registry.discoverModels(provider, { forceRefresh });
757
+ }
758
+ export function formatModelDiscoveryStatus(providers, discoveries) {
759
+ if (providers.some((provider) => discoveries.get(provider.id)?.status !== "complete")) {
760
+ return { text: "Refreshing model catalog…", hasError: false };
761
+ }
762
+ const failed = providers.filter((provider) => discoveries.get(provider.id)?.error);
763
+ if (failed.length > 0) {
764
+ const names = failed.slice(0, 2).map((provider) => provider.name).join(", ");
765
+ const suffix = failed.length > 2 ? ` +${failed.length - 2}` : "";
766
+ return { text: `Showing fallback models for ${names}${suffix} · Ctrl+R retry`, hasError: true };
767
+ }
768
+ return { text: "", hasError: false };
448
769
  }
449
- function preferredModelIndex(options, current) {
450
- const idx = options.findIndex((option) => option.id === current);
451
- return idx >= 0 ? idx : 0;
770
+ function sameModelOptionMetadata(left, right) {
771
+ return left.id === right.id
772
+ && left.label === right.label
773
+ && left.group === right.group
774
+ && left.providerBadge === right.providerBadge
775
+ && left.defaultReasoningLevel === right.defaultReasoningLevel
776
+ && left.contextWindow === right.contextWindow
777
+ && left.toolOutputTokenLimit === right.toolOutputTokenLimit
778
+ && left.available === right.available
779
+ && left.pending === right.pending
780
+ && left.reasoningLevels.length === right.reasoningLevels.length
781
+ && left.reasoningLevels.every((level, index) => level === right.reasoningLevels[index]);
452
782
  }
453
783
  function reasoningLevelsForModel(model) {
454
784
  const { providerId, modelId } = decodeModel(model);