@bubblebrain-ai/bubble 0.0.34 → 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 (168) hide show
  1. package/README.md +27 -7
  2. package/dist/agent/abort-errors.d.ts +2 -2
  3. package/dist/agent/budget-ledger.d.ts +4 -45
  4. package/dist/agent/budget-ledger.js +5 -75
  5. package/dist/agent/categories.d.ts +66 -2
  6. package/dist/agent/categories.js +120 -19
  7. package/dist/agent/child-runner.d.ts +1 -3
  8. package/dist/agent/child-runner.js +6 -37
  9. package/dist/agent/discovery-barrier.js +1 -1
  10. package/dist/agent/profiles.d.ts +1 -2
  11. package/dist/agent/profiles.js +25 -14
  12. package/dist/agent/routing-catalog.d.ts +113 -0
  13. package/dist/agent/routing-catalog.js +240 -0
  14. package/dist/agent/subagent-control.d.ts +5 -12
  15. package/dist/agent/subagent-control.js +0 -2
  16. package/dist/agent/subagent-store.d.ts +1 -1
  17. package/dist/agent/subagent-store.js +1 -3
  18. package/dist/agent/workflow/control.d.ts +21 -0
  19. package/dist/agent/workflow/control.js +50 -8
  20. package/dist/agent/workflow/runtime.d.ts +15 -2
  21. package/dist/agent/workflow/runtime.js +112 -30
  22. package/dist/agent.d.ts +82 -48
  23. package/dist/agent.js +360 -232
  24. package/dist/approval/controller.js +28 -0
  25. package/dist/approval/tool-helper.js +2 -0
  26. package/dist/approval/types.d.ts +21 -1
  27. package/dist/cli.d.ts +1 -1
  28. package/dist/cli.js +2 -1
  29. package/dist/config.d.ts +5 -3
  30. package/dist/config.js +10 -5
  31. package/dist/context/compact-llm.d.ts +2 -2
  32. package/dist/external-runtime/grok-acp.d.ts +56 -0
  33. package/dist/external-runtime/grok-acp.js +386 -0
  34. package/dist/external-runtime/grok-binary.d.ts +25 -0
  35. package/dist/external-runtime/grok-binary.js +108 -0
  36. package/dist/external-runtime/grok-errors.d.ts +7 -0
  37. package/dist/external-runtime/grok-errors.js +15 -0
  38. package/dist/external-runtime/grok-input-policy.d.ts +58 -0
  39. package/dist/external-runtime/grok-input-policy.js +52 -0
  40. package/dist/external-runtime/grok-ndjson.d.ts +14 -0
  41. package/dist/external-runtime/grok-ndjson.js +193 -0
  42. package/dist/external-runtime/grok-network.d.ts +15 -0
  43. package/dist/external-runtime/grok-network.js +81 -0
  44. package/dist/external-runtime/grok-preflight.d.ts +28 -0
  45. package/dist/external-runtime/grok-preflight.js +194 -0
  46. package/dist/external-runtime/grok-process.d.ts +26 -0
  47. package/dist/external-runtime/grok-process.js +184 -0
  48. package/dist/external-runtime/grok-profile.d.ts +32 -0
  49. package/dist/external-runtime/grok-profile.js +419 -0
  50. package/dist/external-runtime/grok-provider.d.ts +31 -0
  51. package/dist/external-runtime/grok-provider.js +41 -0
  52. package/dist/external-runtime/grok-runtime.d.ts +91 -0
  53. package/dist/external-runtime/grok-runtime.js +882 -0
  54. package/dist/external-runtime/index.d.ts +13 -0
  55. package/dist/external-runtime/index.js +13 -0
  56. package/dist/external-runtime/session-policy.d.ts +11 -0
  57. package/dist/external-runtime/session-policy.js +30 -0
  58. package/dist/external-runtime/types.d.ts +65 -0
  59. package/dist/external-runtime/types.js +1 -0
  60. package/dist/feishu/agent-host/approval-card.js +21 -0
  61. package/dist/feishu/agent-host/run-driver.js +19 -3
  62. package/dist/feishu/serve.js +1 -0
  63. package/dist/goal/tools.d.ts +6 -3
  64. package/dist/goal/tools.js +7 -19
  65. package/dist/main.js +114 -28
  66. package/dist/memory/db.d.ts +2 -0
  67. package/dist/memory/db.js +10 -0
  68. package/dist/memory/index.d.ts +1 -0
  69. package/dist/memory/index.js +1 -0
  70. package/dist/memory/phase1.js +14 -2
  71. package/dist/memory/phase2.js +21 -2
  72. package/dist/memory/prompts.js +1 -1
  73. package/dist/memory/session-policy.d.ts +25 -0
  74. package/dist/memory/session-policy.js +55 -0
  75. package/dist/memory/storage.d.ts +1 -0
  76. package/dist/memory/storage.js +13 -0
  77. package/dist/model-catalog.d.ts +18 -0
  78. package/dist/model-catalog.js +92 -52
  79. package/dist/model-config.d.ts +3 -1
  80. package/dist/model-config.js +11 -0
  81. package/dist/oauth/grok.d.ts +25 -0
  82. package/dist/oauth/grok.js +218 -0
  83. package/dist/oauth/index.d.ts +1 -0
  84. package/dist/oauth/index.js +1 -0
  85. package/dist/oauth/storage.d.ts +9 -0
  86. package/dist/oauth/storage.js +25 -0
  87. package/dist/orchestrator/default-hooks.js +23 -7
  88. package/dist/prompt/compose.d.ts +3 -0
  89. package/dist/prompt/compose.js +3 -0
  90. package/dist/prompt/delegation.d.ts +1 -1
  91. package/dist/prompt/delegation.js +41 -8
  92. package/dist/prompt/environment.js +0 -4
  93. package/dist/prompt/reminders.d.ts +0 -1
  94. package/dist/prompt/reminders.js +1 -11
  95. package/dist/prompt/routing.d.ts +14 -0
  96. package/dist/prompt/routing.js +71 -0
  97. package/dist/prompt/runtime.js +1 -1
  98. package/dist/prompt/task-reminders.d.ts +20 -0
  99. package/dist/prompt/task-reminders.js +73 -1
  100. package/dist/provider-ai-sdk.js +2 -2
  101. package/dist/provider-grok.d.ts +26 -0
  102. package/dist/provider-grok.js +59 -0
  103. package/dist/provider-openai-codex.d.ts +17 -1
  104. package/dist/provider-openai-codex.js +101 -29
  105. package/dist/provider-registry.d.ts +72 -0
  106. package/dist/provider-registry.js +336 -59
  107. package/dist/provider-transform.d.ts +1 -1
  108. package/dist/provider-transform.js +35 -17
  109. package/dist/provider.d.ts +3 -0
  110. package/dist/provider.js +14 -0
  111. package/dist/session-types.d.ts +19 -1
  112. package/dist/session.d.ts +1 -0
  113. package/dist/session.js +4 -0
  114. package/dist/slash-commands/commands.js +224 -14
  115. package/dist/slash-commands/types.d.ts +14 -1
  116. package/dist/system-prompt.d.ts +2 -0
  117. package/dist/tools/agent-lifecycle.d.ts +1 -9
  118. package/dist/tools/agent-lifecycle.js +42 -272
  119. package/dist/tools/bash.js +1 -1
  120. package/dist/tools/child-tools.js +2 -0
  121. package/dist/tools/index.d.ts +2 -2
  122. package/dist/tools/index.js +7 -5
  123. package/dist/tools/lsp.js +1 -0
  124. package/dist/tools/memory.d.ts +7 -2
  125. package/dist/tools/memory.js +28 -43
  126. package/dist/tools/server.js +4 -0
  127. package/dist/tools/tool-search.d.ts +1 -1
  128. package/dist/tools/tool-search.js +23 -3
  129. package/dist/tui/model-switch.d.ts +7 -0
  130. package/dist/tui/model-switch.js +15 -5
  131. package/dist/tui-ink/app.d.ts +3 -1
  132. package/dist/tui-ink/app.js +672 -243
  133. package/dist/tui-ink/approval/approval-dialog.js +37 -1
  134. package/dist/tui-ink/approval/diff-view.js +4 -4
  135. package/dist/tui-ink/approval/select.js +2 -2
  136. package/dist/tui-ink/composer-buffer.d.ts +55 -0
  137. package/dist/tui-ink/composer-buffer.js +317 -0
  138. package/dist/tui-ink/feedback-dialog.js +2 -2
  139. package/dist/tui-ink/feishu-setup-picker.js +3 -3
  140. package/dist/tui-ink/footer.d.ts +1 -0
  141. package/dist/tui-ink/footer.js +7 -2
  142. package/dist/tui-ink/input-box.d.ts +36 -7
  143. package/dist/tui-ink/input-box.js +229 -157
  144. package/dist/tui-ink/markdown.js +11 -9
  145. package/dist/tui-ink/message-list.d.ts +5 -1
  146. package/dist/tui-ink/message-list.js +15 -2
  147. package/dist/tui-ink/model-picker.d.ts +61 -4
  148. package/dist/tui-ink/model-picker.js +464 -128
  149. package/dist/tui-ink/paste-operation-tracker.d.ts +13 -0
  150. package/dist/tui-ink/paste-operation-tracker.js +25 -0
  151. package/dist/tui-ink/plan-confirm.js +1 -1
  152. package/dist/tui-ink/run.d.ts +3 -0
  153. package/dist/tui-ink/run.js +1 -1
  154. package/dist/tui-ink/subagent-view.d.ts +34 -1
  155. package/dist/tui-ink/subagent-view.js +82 -1
  156. package/dist/tui-ink/theme.d.ts +19 -2
  157. package/dist/tui-ink/theme.js +25 -8
  158. package/dist/tui-ink/welcome.d.ts +3 -2
  159. package/dist/tui-ink/welcome.js +10 -6
  160. package/dist/types.d.ts +8 -31
  161. package/dist/types.js +1 -1
  162. package/dist/variant/thinking-level.d.ts +2 -2
  163. package/dist/variant/thinking-level.js +2 -1
  164. package/dist/variant/variant-resolver.d.ts +22 -0
  165. package/dist/variant/variant-resolver.js +43 -0
  166. package/package.json +6 -2
  167. package/dist/tools/task.d.ts +0 -2
  168. package/dist/tools/task.js +0 -58
@@ -1,13 +1,13 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useEffect, useMemo } from "react";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
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";
10
- import { getAvailableThinkingLevels, normalizeThinkingLevel } from "../provider-transform.js";
10
+ import { getAvailableThinkingLevels, isThinkingOnlyLevels, isThinkingToggleModel, normalizeThinkingLevel } from "../provider-transform.js";
11
11
  export { padVisual, truncateVisual } from "../text-display.js";
12
12
  export function resolvePickerKeyAction(input, key) {
13
13
  if (key.escape)
@@ -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,25 +79,98 @@ export function padPickerRows(rows, bodyRows, width) {
78
79
  }
79
80
  return padded;
80
81
  }
81
- // MiniMax models expose thinking as a binary on/off switch (the API's `thinking`
82
- // param is disabled|adaptive — there's no graded effort), so render the "on"
83
- // level as on/off instead of our internal "medium". Scoped to MiniMax only —
84
- // other 2-level models (e.g. GLM toggles) keep their effort labels.
85
- function isMiniMaxToggleModel(modelId) {
86
- return modelId.toLowerCase().includes("minimax");
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
+ }
151
+ function isThinkingToggleOption(option) {
152
+ const decoded = decodeModel(option.id);
153
+ return isThinkingToggleModel(decoded.providerId || option.providerBadge, decoded.modelId);
87
154
  }
88
155
  export function formatReasoningLevelsLabel(levels, asToggle = false) {
89
- const normalized = levels.length > 0 ? levels : ["off"];
156
+ if (levels.length === 0)
157
+ return "checking";
158
+ if (isThinkingOnlyLevels(levels))
159
+ return "on";
90
160
  if (asToggle)
91
161
  return "thinking on/off";
92
- return `effort ${normalized.join("/")}`;
162
+ return levels.join("/");
93
163
  }
94
164
  export function formatModelPickerRow(option, options) {
95
165
  const width = Math.max(24, options.width);
96
166
  const marker = options.selected ? "> " : " ";
97
167
  const label = option.label.replace(/\s+/g, " ").trim();
98
168
  const provider = option.providerBadge.replace(/\s+/g, " ").trim();
99
- const effort = formatReasoningLevelsLabel(option.reasoningLevels, isMiniMaxToggleModel(option.id));
169
+ const effort = option.pending
170
+ ? "checking"
171
+ : (option.available === false || option.reasoningLevels.length === 0)
172
+ ? "unavailable"
173
+ : formatReasoningLevelsLabel(option.reasoningLevels, isThinkingToggleOption(option));
100
174
  const current = options.current ? " ●" : "";
101
175
  const providerWidth = Math.max(6, Math.min(16, Math.floor(width * 0.18)));
102
176
  const effortWidth = Math.max(12, Math.min(30, Math.floor(width * 0.32)));
@@ -128,6 +202,14 @@ export function formatNoModelResultsRow(query, width) {
128
202
  return padVisual(truncateVisual(row, rowWidth), rowWidth);
129
203
  }
130
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
+ }
131
213
  const preferred = normalizeThinkingLevel(currentThinkingLevel, option.reasoningLevels);
132
214
  const index = option.reasoningLevels.indexOf(preferred);
133
215
  return index >= 0 ? index : 0;
@@ -135,6 +217,46 @@ export function preferredEffortIndex(option, currentThinkingLevel) {
135
217
  export function shouldOpenEffortPicker(option) {
136
218
  return option.reasoningLevels.length > 1;
137
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
+ }
138
260
  function effortDescription(level, asToggle) {
139
261
  if (asToggle)
140
262
  return level === "off" ? "thinking disabled" : "thinking enabled";
@@ -153,6 +275,8 @@ function effortDescription(level, asToggle) {
153
275
  return "extra high reasoning";
154
276
  case "max":
155
277
  return "maximum provider effort";
278
+ case "ultra":
279
+ return "maximum effort with delegation";
156
280
  default:
157
281
  return "reasoning effort";
158
282
  }
@@ -170,81 +294,97 @@ export function ModelPicker({ registry, current, currentThinkingLevel, recent, o
170
294
  const terminalColumns = stdout?.columns || 80;
171
295
  const bodyRows = modelPickerBodyRows(termHeight);
172
296
  const rowWidth = Math.max(36, Math.min(110, terminalColumns - 6));
173
- 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]);
174
323
  const [query, setQuery] = useState("");
175
- const [selectedIndex, setSelectedIndex] = useState(() => preferredModelIndex(buildLocalModelOptions(registry, current, recent), current));
324
+ const [selectedId, setSelectedId] = useState(() => resolveSelectedModelId(buildLocalModelOptions(registry, current, recent), current));
176
325
  const [phase, setPhase] = useState({ kind: "model" });
177
326
  useEffect(() => {
178
- let cancelled = false;
179
- const localOptions = buildLocalModelOptions(registry, current, recent);
180
- setRawOptions(localOptions);
181
- setSelectedIndex(preferredModelIndex(localOptions, current));
182
- async function refreshRemote() {
183
- const enabled = registry.getEnabled();
184
- const opts = [];
185
- const seen = new Set();
186
- // Recent first
187
- for (const m of recent.slice(0, 5)) {
188
- const { providerId } = decodeModel(m);
189
- const provider = enabled.find((p) => p.id === providerId);
190
- appendModelOption(opts, seen, {
191
- id: m,
192
- label: displayModel(m),
193
- group: "Recent",
194
- 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,
195
337
  });
196
338
  }
197
- const visibleProviders = enabled.filter((item) => isUserVisibleProvider(item.id));
198
- const discovered = await Promise.all(visibleProviders.map(async (provider) => {
199
- try {
200
- return { provider, models: await registry.listModels(provider) };
201
- }
202
- catch {
203
- return { provider, models: localModelsForProvider(registry, provider) };
204
- }
205
- }));
206
- for (const { provider, models } of discovered) {
207
- for (const m of models) {
208
- appendModelOption(opts, seen, {
209
- id: encodeModel(m.providerId, m.id),
210
- label: m.name,
211
- group: provider.name,
212
- providerBadge: provider.name,
213
- });
214
- }
215
- }
216
- if (current && !seen.has(current)) {
217
- const { providerId } = decodeModel(current);
218
- const provider = enabled.find((p) => p.id === providerId);
219
- opts.unshift({
220
- id: current,
221
- label: displayModel(current),
222
- group: "Current",
223
- providerBadge: provider?.name || providerId || "",
224
- 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;
225
350
  });
226
- }
227
- if (!cancelled) {
228
- setRawOptions(opts);
229
- setSelectedIndex((index) => {
230
- const currentIndex = preferredModelIndex(opts, current);
231
- return index === preferredModelIndex(localOptions, current)
232
- ? currentIndex
233
- : 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;
234
365
  });
235
- }
366
+ });
236
367
  }
237
- void refreshRemote();
238
368
  return () => {
239
- cancelled = true;
369
+ if (requestGenerationRef.current === generation)
370
+ requestGenerationRef.current++;
240
371
  };
241
- }, [registry, current, recent]);
372
+ }, [registry, providerKey, refreshGeneration]);
242
373
  const options = useMemo(() => {
243
- if (!query.trim())
244
- return rawOptions;
245
- const q = query.toLowerCase();
246
- return rawOptions.filter((opt) => opt.label.toLowerCase().includes(q) || opt.providerBadge.toLowerCase().includes(q));
374
+ return filterAndRankModelPickerOptions(rawOptions, query);
247
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);
248
388
  useInput((input, key) => {
249
389
  if (isKeyReleaseEvent(key))
250
390
  return;
@@ -256,7 +396,9 @@ export function ModelPicker({ registry, current, currentThinkingLevel, recent, o
256
396
  return;
257
397
  }
258
398
  if (action === "enter") {
259
- 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);
260
402
  return;
261
403
  }
262
404
  if (action === "up") {
@@ -273,13 +415,17 @@ export function ModelPicker({ registry, current, currentThinkingLevel, recent, o
273
415
  }
274
416
  return;
275
417
  }
418
+ if (key.ctrl && (input.toLowerCase() === "r" || input === "\x12")) {
419
+ setRefreshGeneration((generation) => generation + 1);
420
+ return;
421
+ }
276
422
  if (action === "escape") {
277
423
  onCancel();
278
424
  return;
279
425
  }
280
426
  if (action === "enter") {
281
- const opt = options[clampPickerIndex(selectedIndex, options.length)];
282
- if (opt) {
427
+ const opt = selectedIndex >= 0 ? options[selectedIndex] : undefined;
428
+ if (opt && isModelOptionSelectable(opt)) {
283
429
  if (shouldOpenEffortPicker(opt)) {
284
430
  setPhase({
285
431
  kind: "effort",
@@ -288,23 +434,25 @@ export function ModelPicker({ registry, current, currentThinkingLevel, recent, o
288
434
  });
289
435
  }
290
436
  else {
291
- onSelect(opt.id, opt.reasoningLevels[0] ?? "off");
437
+ const level = opt.reasoningLevels[0];
438
+ if (level)
439
+ onSelect(opt.id, level);
292
440
  }
293
441
  }
294
442
  return;
295
443
  }
296
444
  if (action === "up") {
297
- setSelectedIndex((i) => clampPickerIndex(i - 1, options.length));
445
+ setSelectedId((id) => moveModelSelection(options, id, -1));
298
446
  return;
299
447
  }
300
448
  if (action === "down") {
301
- setSelectedIndex((i) => clampPickerIndex(i + 1, options.length));
449
+ setSelectedId((id) => moveModelSelection(options, id, 1));
302
450
  return;
303
451
  }
304
452
  if (action === "backspace" || action === "delete") {
305
453
  setQuery((q) => {
306
454
  const next = q.slice(0, -1);
307
- setSelectedIndex(0);
455
+ setSelectedId(undefined);
308
456
  return next;
309
457
  });
310
458
  return;
@@ -312,40 +460,60 @@ export function ModelPicker({ registry, current, currentThinkingLevel, recent, o
312
460
  if (isPrintablePickerInput(input) && !key.ctrl && !key.meta) {
313
461
  setQuery((q) => {
314
462
  const next = q + input;
315
- setSelectedIndex(0);
463
+ setSelectedId(undefined);
316
464
  return next;
317
465
  });
318
466
  return;
319
467
  }
320
468
  });
321
- const safeSelectedIndex = clampPickerIndex(selectedIndex, options.length);
322
- const start = pickerWindowStart(safeSelectedIndex, options.length, bodyRows);
323
- 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);
324
473
  const rawModelRows = options.length === 0
325
474
  ? [{
326
475
  key: "no-results",
327
476
  row: formatNoModelResultsRow(query, rowWidth),
328
477
  selected: false,
478
+ header: false,
329
479
  }]
330
- : visible.map((opt, i) => {
331
- const actualIndex = start + i;
332
- 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;
333
490
  return {
334
- key: opt.id,
335
- row: formatModelPickerRow(opt, {
491
+ key: item.key,
492
+ row: formatModelPickerRow(item.option, {
336
493
  selected: isSelected,
337
- current: opt.id === current,
494
+ current: item.option.id === current,
338
495
  width: rowWidth,
339
496
  }),
340
497
  selected: isSelected,
498
+ header: false,
341
499
  };
342
500
  });
343
501
  const modelRows = padPickerRows(rawModelRows.map((row) => row.row), bodyRows, rowWidth).map((row, index) => ({
344
502
  key: rawModelRows[index]?.key ?? `blank-${index}`,
345
503
  row,
346
504
  selected: rawModelRows[index]?.selected ?? false,
505
+ header: rawModelRows[index]?.header ?? false,
347
506
  }));
348
- 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" ? "Select Reasoning Effort" : "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 }), _jsx(Text, { color: theme.muted, children: "\u2191/\u2193 navigate \u00B7 Enter choose effort \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))) })] }));
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";
513
+ const effortTitle = phase.kind === "effort" && isThinkingToggleOption(phase.model)
514
+ ? "Select Thinking Mode"
515
+ : "Select Reasoning Effort";
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))) })] }));
349
517
  }
350
518
  function EffortPickerView({ model, selectedIndex, bodyRows, rowWidth, }) {
351
519
  const theme = useTheme();
@@ -355,7 +523,7 @@ function EffortPickerView({ model, selectedIndex, bodyRows, rowWidth, }) {
355
523
  row: formatEffortPickerRow(level, {
356
524
  selected: index === safeSelectedIndex,
357
525
  width: rowWidth,
358
- asToggle: isMiniMaxToggleModel(model.id),
526
+ asToggle: isThinkingToggleOption(model),
359
527
  }),
360
528
  selected: index === safeSelectedIndex,
361
529
  }));
@@ -382,40 +550,124 @@ function SearchField({ query, placeholder, width }) {
382
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 })] }));
383
551
  }
384
552
  export function buildLocalModelOptions(registry, current, recent) {
385
- 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) {
386
567
  const opts = [];
387
568
  const seen = new Set();
388
- for (const model of recent.slice(0, 5)) {
389
- const { providerId } = decodeModel(model);
390
- const provider = enabled.find((item) => item.id === providerId);
391
- appendModelOption(opts, seen, {
392
- id: model,
393
- label: displayModel(model),
394
- group: "Recent",
395
- providerBadge: provider?.name || providerId || "",
396
- });
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");
397
623
  }
398
- for (const provider of enabled.filter((item) => isUserVisibleProvider(item.id))) {
399
- 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) ?? []) {
400
662
  appendModelOption(opts, seen, {
401
- id: encodeModel(model.providerId, model.id),
402
- label: model.name,
663
+ ...modelOptionMetadata(encodeModel(provider.id, model.id), model, true),
403
664
  group: provider.name,
404
665
  providerBadge: provider.name,
666
+ available: true,
667
+ pending: false,
405
668
  });
406
669
  }
407
670
  }
408
- if (current && !seen.has(current)) {
409
- const { providerId } = decodeModel(current);
410
- const provider = enabled.find((item) => item.id === providerId);
411
- opts.unshift({
412
- id: current,
413
- label: displayModel(current),
414
- group: "Current",
415
- providerBadge: provider?.name || providerId || "",
416
- reasoningLevels: reasoningLevelsForModel(current),
417
- });
418
- }
419
671
  return opts;
420
672
  }
421
673
  function localModelsForProvider(registry, provider) {
@@ -429,20 +681,104 @@ function localModelsForProvider(registry, provider) {
429
681
  id: model.id,
430
682
  name: model.name,
431
683
  providerId: provider.id,
684
+ reasoningLevels: model.reasoningLevels,
685
+ defaultReasoningLevel: model.defaultReasoningLevel,
686
+ contextWindow: model.contextWindow,
687
+ toolOutputTokenLimit: model.toolOutputTokenLimit,
432
688
  }));
433
689
  }
434
690
  function appendModelOption(options, seen, option) {
435
691
  if (seen.has(option.id))
436
692
  return;
437
693
  seen.add(option.id);
438
- options.push({
439
- ...option,
440
- reasoningLevels: option.reasoningLevels ?? reasoningLevelsForModel(option.id),
441
- });
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 };
442
769
  }
443
- function preferredModelIndex(options, current) {
444
- const idx = options.findIndex((option) => option.id === current);
445
- 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]);
446
782
  }
447
783
  function reasoningLevelsForModel(model) {
448
784
  const { providerId, modelId } = decodeModel(model);