@bubblebrain-ai/bubble 0.0.35 → 0.0.37

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 (144) 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 +649 -233
  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/message-list.js +5 -5
  128. package/dist/tui-ink/model-picker.d.ts +61 -4
  129. package/dist/tui-ink/model-picker.js +486 -133
  130. package/dist/tui-ink/paste-operation-tracker.d.ts +13 -0
  131. package/dist/tui-ink/paste-operation-tracker.js +25 -0
  132. package/dist/tui-ink/run.d.ts +3 -0
  133. package/dist/tui-ink/run.js +1 -1
  134. package/dist/tui-ink/subagent-view.d.ts +31 -0
  135. package/dist/tui-ink/subagent-view.js +82 -1
  136. package/dist/types.d.ts +5 -5
  137. package/dist/types.js +1 -1
  138. package/dist/variant/thinking-level.d.ts +2 -2
  139. package/dist/variant/thinking-level.js +2 -1
  140. package/dist/variant/variant-resolver.d.ts +9 -0
  141. package/dist/variant/variant-resolver.js +17 -0
  142. package/package.json +2 -1
  143. package/dist/tools/task.d.ts +0 -2
  144. 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";
@@ -22,10 +22,17 @@ export function resolvePickerKeyAction(input, key) {
22
22
  return "backspace";
23
23
  if (key.delete)
24
24
  return "delete";
25
+ // Batched chunks: slow ptys (web-terminal bridges, SSH, paste) can deliver
26
+ // several keys in ONE input event, in which case ink sets no key flags at
27
+ // all ("\r/quit\r" arrives with return=false). Act on the chunk's leading
28
+ // key instead of swallowing it — exact-match anchors would drop the whole
29
+ // chunk.
30
+ if (input.startsWith("\r") || input.startsWith("\n"))
31
+ return "enter";
25
32
  const sequence = normalizeEscapeSequence(input);
26
- if (/^(?:O|\[[\d;:]*)A$/.test(sequence))
33
+ if (/^(?:O|\[[\d;:]*)A/.test(sequence))
27
34
  return "up";
28
- if (/^(?:O|\[[\d;:]*)B$/.test(sequence))
35
+ if (/^(?:O|\[[\d;:]*)B/.test(sequence))
29
36
  return "down";
30
37
  return undefined;
31
38
  }
@@ -53,7 +60,8 @@ export function formatSkillPickerRow(skill, options) {
53
60
  return padVisual(truncateVisual(row, width), width);
54
61
  }
55
62
  export const MODEL_PICKER_MAX_BODY_ROWS = 10;
56
- export const MODEL_PICKER_CHROME_ROWS = 13;
63
+ export const MODEL_PICKER_CHROME_ROWS = 14;
64
+ export const MODEL_PICKER_RECENT_FAMILY_LIMIT = 3;
57
65
  export function modelPickerBodyRows(termHeight) {
58
66
  const rows = Number.isFinite(termHeight) ? Math.floor(termHeight) : 24;
59
67
  return Math.max(1, Math.min(MODEL_PICKER_MAX_BODY_ROWS, rows - MODEL_PICKER_CHROME_ROWS));
@@ -78,24 +86,98 @@ export function padPickerRows(rows, bodyRows, width) {
78
86
  }
79
87
  return padded;
80
88
  }
89
+ export function modelPickerRecentKey(recent) {
90
+ return recent.join("\u0000");
91
+ }
92
+ /**
93
+ * Returns a provider-scoped family key for model ordering.
94
+ *
95
+ * Keep this deliberately conservative: suffixes such as `mini`, `codex`,
96
+ * `spark`, or `highspeed` often describe distinct products rather than
97
+ * interchangeable siblings. The Codex Sol/Terra/Luna trio is the one family
98
+ * whose relationship is explicitly declared by the provider catalog.
99
+ */
100
+ export function modelPickerFamilyKey(provider, modelId) {
101
+ const isCodexOAuth = provider.id === "openai-codex"
102
+ || (provider.id === "openai" && provider.authType === "oauth");
103
+ if (isCodexOAuth) {
104
+ const match = /^(gpt-\d+(?:\.\d+)+)-(sol|terra|luna)$/i.exec(modelId);
105
+ if (match)
106
+ return `${provider.id}:${match[1].toLowerCase()}:sol-terra-luna`;
107
+ }
108
+ return `${provider.id}:${modelId}`;
109
+ }
110
+ export function buildModelPickerDisplayItems(options) {
111
+ const items = [];
112
+ let previousGroup;
113
+ let headerIndex = 0;
114
+ for (const option of options) {
115
+ if (option.group !== previousGroup) {
116
+ previousGroup = option.group;
117
+ items.push({
118
+ kind: "header",
119
+ key: `header:${headerIndex++}:${option.group}`,
120
+ label: option.group,
121
+ });
122
+ }
123
+ items.push({ kind: "model", key: option.id, option });
124
+ }
125
+ return items;
126
+ }
127
+ export function resolveModelPickerDisplayIndex(items, selectedId) {
128
+ if (selectedId) {
129
+ const selectedIndex = items.findIndex((item) => item.kind === "model" && item.option.id === selectedId);
130
+ if (selectedIndex >= 0)
131
+ return selectedIndex;
132
+ }
133
+ const firstModelIndex = items.findIndex((item) => item.kind === "model");
134
+ return firstModelIndex >= 0 ? firstModelIndex : 0;
135
+ }
136
+ export function filterAndRankModelPickerOptions(options, query) {
137
+ const normalized = query.trim().toLowerCase();
138
+ if (!normalized)
139
+ return [...options];
140
+ return options
141
+ .map((option, index) => {
142
+ const { modelId } = decodeModel(option.id);
143
+ const fields = [option.label, modelId, option.id, option.providerBadge]
144
+ .map((value) => value.toLowerCase());
145
+ const rank = fields.some((value) => value === normalized)
146
+ ? 0
147
+ : fields.some((value) => value.startsWith(normalized))
148
+ ? 1
149
+ : fields.some((value) => value.includes(normalized))
150
+ ? 2
151
+ : Number.POSITIVE_INFINITY;
152
+ return { option, index, rank };
153
+ })
154
+ .filter((entry) => Number.isFinite(entry.rank))
155
+ .sort((left, right) => left.rank - right.rank || left.index - right.index)
156
+ .map((entry) => entry.option);
157
+ }
81
158
  function isThinkingToggleOption(option) {
82
159
  const decoded = decodeModel(option.id);
83
160
  return isThinkingToggleModel(decoded.providerId || option.providerBadge, decoded.modelId);
84
161
  }
85
162
  export function formatReasoningLevelsLabel(levels, asToggle = false) {
163
+ if (levels.length === 0)
164
+ return "checking";
86
165
  if (isThinkingOnlyLevels(levels))
87
166
  return "on";
88
167
  if (asToggle)
89
168
  return "thinking on/off";
90
- const normalized = levels.length > 0 ? levels : ["off"];
91
- return normalized.join("/");
169
+ return levels.join("/");
92
170
  }
93
171
  export function formatModelPickerRow(option, options) {
94
172
  const width = Math.max(24, options.width);
95
173
  const marker = options.selected ? "> " : " ";
96
174
  const label = option.label.replace(/\s+/g, " ").trim();
97
175
  const provider = option.providerBadge.replace(/\s+/g, " ").trim();
98
- const effort = formatReasoningLevelsLabel(option.reasoningLevels, isThinkingToggleOption(option));
176
+ const effort = option.pending
177
+ ? "checking"
178
+ : (option.available === false || option.reasoningLevels.length === 0)
179
+ ? "unavailable"
180
+ : formatReasoningLevelsLabel(option.reasoningLevels, isThinkingToggleOption(option));
99
181
  const current = options.current ? " ●" : "";
100
182
  const providerWidth = Math.max(6, Math.min(16, Math.floor(width * 0.18)));
101
183
  const effortWidth = Math.max(12, Math.min(30, Math.floor(width * 0.32)));
@@ -127,6 +209,14 @@ export function formatNoModelResultsRow(query, width) {
127
209
  return padVisual(truncateVisual(row, rowWidth), rowWidth);
128
210
  }
129
211
  export function preferredEffortIndex(option, currentThinkingLevel) {
212
+ const currentIndex = option.reasoningLevels.indexOf(currentThinkingLevel);
213
+ if (currentIndex >= 0)
214
+ return currentIndex;
215
+ if (option.defaultReasoningLevel) {
216
+ const defaultIndex = option.reasoningLevels.indexOf(option.defaultReasoningLevel);
217
+ if (defaultIndex >= 0)
218
+ return defaultIndex;
219
+ }
130
220
  const preferred = normalizeThinkingLevel(currentThinkingLevel, option.reasoningLevels);
131
221
  const index = option.reasoningLevels.indexOf(preferred);
132
222
  return index >= 0 ? index : 0;
@@ -134,6 +224,46 @@ export function preferredEffortIndex(option, currentThinkingLevel) {
134
224
  export function shouldOpenEffortPicker(option) {
135
225
  return option.reasoningLevels.length > 1;
136
226
  }
227
+ export function isModelOptionSelectable(option) {
228
+ return option.available && !option.pending && option.reasoningLevels.length > 0;
229
+ }
230
+ export function resolveSelectedModelId(options, selectedId) {
231
+ if (selectedId) {
232
+ const selected = options.find((option) => option.id === selectedId);
233
+ if (selected && isModelOptionSelectable(selected))
234
+ return selected.id;
235
+ }
236
+ return options.find(isModelOptionSelectable)?.id;
237
+ }
238
+ export function moveModelSelection(options, selectedId, direction) {
239
+ const selectableIndexes = options
240
+ .map((option, index) => isModelOptionSelectable(option) ? index : -1)
241
+ .filter((index) => index >= 0);
242
+ if (selectableIndexes.length === 0)
243
+ return undefined;
244
+ const currentIndex = options.findIndex((option) => option.id === selectedId);
245
+ if (currentIndex < 0) {
246
+ return options[direction > 0 ? selectableIndexes[0] : selectableIndexes[selectableIndexes.length - 1]]?.id;
247
+ }
248
+ const target = direction > 0
249
+ ? selectableIndexes.find((index) => index > currentIndex)
250
+ : [...selectableIndexes].reverse().find((index) => index < currentIndex);
251
+ return options[target ?? currentIndex]?.id ?? selectedId;
252
+ }
253
+ export function reconcileModelPickerPhase(phase, options, currentThinkingLevel) {
254
+ if (phase.kind !== "effort")
255
+ return phase;
256
+ const latest = options.find((option) => option.id === phase.model.id);
257
+ if (!latest || !isModelOptionSelectable(latest))
258
+ return { kind: "model" };
259
+ const selectedLevel = phase.model.reasoningLevels[phase.selectedIndex];
260
+ const selectedIndex = selectedLevel && latest.reasoningLevels.includes(selectedLevel)
261
+ ? latest.reasoningLevels.indexOf(selectedLevel)
262
+ : preferredEffortIndex(latest, currentThinkingLevel);
263
+ if (selectedIndex === phase.selectedIndex && sameModelOptionMetadata(phase.model, latest))
264
+ return phase;
265
+ return { kind: "effort", model: latest, selectedIndex };
266
+ }
137
267
  function effortDescription(level, asToggle) {
138
268
  if (asToggle)
139
269
  return level === "off" ? "thinking disabled" : "thinking enabled";
@@ -152,6 +282,8 @@ function effortDescription(level, asToggle) {
152
282
  return "extra high reasoning";
153
283
  case "max":
154
284
  return "maximum provider effort";
285
+ case "ultra":
286
+ return "maximum effort with delegation";
155
287
  default:
156
288
  return "reasoning effort";
157
289
  }
@@ -169,81 +301,97 @@ export function ModelPicker({ registry, current, currentThinkingLevel, recent, o
169
301
  const terminalColumns = stdout?.columns || 80;
170
302
  const bodyRows = modelPickerBodyRows(termHeight);
171
303
  const rowWidth = Math.max(36, Math.min(110, terminalColumns - 6));
172
- const [rawOptions, setRawOptions] = useState(() => buildLocalModelOptions(registry, current, recent));
304
+ const recentKey = modelPickerRecentKey(recent);
305
+ const enabledSnapshot = registry.getEnabled().filter((provider) => isUserVisibleProvider(provider.id));
306
+ const providerKey = enabledSnapshot
307
+ .map((provider) => [
308
+ provider.id,
309
+ provider.name,
310
+ provider.baseURL,
311
+ provider.authType ?? "",
312
+ provider.protocol ?? "",
313
+ ].join("\u0000"))
314
+ .join("\u0001");
315
+ const enabledProviders = useMemo(() => registry.getEnabled().filter((provider) => isUserVisibleProvider(provider.id)), [registry, providerKey]);
316
+ const localModelsByProvider = useMemo(() => new Map(enabledProviders.map((provider) => [
317
+ provider.id,
318
+ localModelsForProvider(registry, provider),
319
+ ])), [registry, providerKey]);
320
+ const [discoveries, setDiscoveries] = useState(() => new Map());
321
+ const [refreshGeneration, setRefreshGeneration] = useState(0);
322
+ const requestGenerationRef = useRef(0);
323
+ const rawOptions = useMemo(() => buildMergedModelOptions({
324
+ providers: enabledProviders,
325
+ localModelsByProvider,
326
+ discoveries,
327
+ current,
328
+ recent,
329
+ }), [enabledProviders, localModelsByProvider, discoveries, current, recentKey]);
173
330
  const [query, setQuery] = useState("");
174
- const [selectedIndex, setSelectedIndex] = useState(() => preferredModelIndex(buildLocalModelOptions(registry, current, recent), current));
331
+ const [selectedId, setSelectedId] = useState(() => resolveSelectedModelId(buildLocalModelOptions(registry, current, recent), current));
175
332
  const [phase, setPhase] = useState({ kind: "model" });
176
333
  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 || "",
334
+ const generation = ++requestGenerationRef.current;
335
+ setDiscoveries((previous) => {
336
+ const next = new Map();
337
+ for (const provider of enabledProviders) {
338
+ const prior = previous.get(provider.id);
339
+ next.set(provider.id, {
340
+ status: "pending",
341
+ models: prior?.models ?? [],
342
+ source: prior?.source,
343
+ authoritative: prior?.authoritative ?? false,
194
344
  });
195
345
  }
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),
346
+ return next;
347
+ });
348
+ for (const provider of enabledProviders) {
349
+ void discoverModelsForPicker(registry, provider, refreshGeneration > 0)
350
+ .then((result) => {
351
+ if (requestGenerationRef.current !== generation)
352
+ return;
353
+ setDiscoveries((previous) => {
354
+ const next = new Map(previous);
355
+ next.set(provider.id, { status: "complete", ...result });
356
+ return next;
224
357
  });
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);
358
+ })
359
+ .catch((error) => {
360
+ if (requestGenerationRef.current !== generation)
361
+ return;
362
+ setDiscoveries((previous) => {
363
+ const next = new Map(previous);
364
+ next.set(provider.id, {
365
+ status: "complete",
366
+ models: localModelsByProvider.get(provider.id) ?? [],
367
+ source: "fallback",
368
+ authoritative: false,
369
+ error: error instanceof Error ? error.message : String(error),
370
+ });
371
+ return next;
233
372
  });
234
- }
373
+ });
235
374
  }
236
- void refreshRemote();
237
375
  return () => {
238
- cancelled = true;
376
+ if (requestGenerationRef.current === generation)
377
+ requestGenerationRef.current++;
239
378
  };
240
- }, [registry, current, recent]);
379
+ }, [registry, providerKey, refreshGeneration]);
241
380
  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));
381
+ return filterAndRankModelPickerOptions(rawOptions, query);
246
382
  }, [rawOptions, query]);
383
+ const resolvedSelectedId = resolveSelectedModelId(options, selectedId);
384
+ const selectedIndex = resolvedSelectedId
385
+ ? options.findIndex((option) => option.id === resolvedSelectedId)
386
+ : -1;
387
+ useEffect(() => {
388
+ if (selectedId !== resolvedSelectedId)
389
+ setSelectedId(resolvedSelectedId);
390
+ }, [selectedId, resolvedSelectedId]);
391
+ useEffect(() => {
392
+ setPhase((currentPhase) => reconcileModelPickerPhase(currentPhase, rawOptions, currentThinkingLevel));
393
+ }, [rawOptions, currentThinkingLevel]);
394
+ const discoveryStatus = formatModelDiscoveryStatus(enabledProviders, discoveries);
247
395
  useInput((input, key) => {
248
396
  if (isKeyReleaseEvent(key))
249
397
  return;
@@ -255,7 +403,9 @@ export function ModelPicker({ registry, current, currentThinkingLevel, recent, o
255
403
  return;
256
404
  }
257
405
  if (action === "enter") {
258
- onSelect(phase.model.id, levels[clampPickerIndex(phase.selectedIndex, levels.length)] ?? "off");
406
+ const level = levels[clampPickerIndex(phase.selectedIndex, levels.length)];
407
+ if (level)
408
+ onSelect(phase.model.id, level);
259
409
  return;
260
410
  }
261
411
  if (action === "up") {
@@ -272,13 +422,17 @@ export function ModelPicker({ registry, current, currentThinkingLevel, recent, o
272
422
  }
273
423
  return;
274
424
  }
425
+ if (key.ctrl && (input.toLowerCase() === "r" || input === "\x12")) {
426
+ setRefreshGeneration((generation) => generation + 1);
427
+ return;
428
+ }
275
429
  if (action === "escape") {
276
430
  onCancel();
277
431
  return;
278
432
  }
279
433
  if (action === "enter") {
280
- const opt = options[clampPickerIndex(selectedIndex, options.length)];
281
- if (opt) {
434
+ const opt = selectedIndex >= 0 ? options[selectedIndex] : undefined;
435
+ if (opt && isModelOptionSelectable(opt)) {
282
436
  if (shouldOpenEffortPicker(opt)) {
283
437
  setPhase({
284
438
  kind: "effort",
@@ -287,23 +441,25 @@ export function ModelPicker({ registry, current, currentThinkingLevel, recent, o
287
441
  });
288
442
  }
289
443
  else {
290
- onSelect(opt.id, opt.reasoningLevels[0] ?? "off");
444
+ const level = opt.reasoningLevels[0];
445
+ if (level)
446
+ onSelect(opt.id, level);
291
447
  }
292
448
  }
293
449
  return;
294
450
  }
295
451
  if (action === "up") {
296
- setSelectedIndex((i) => clampPickerIndex(i - 1, options.length));
452
+ setSelectedId((id) => moveModelSelection(options, id, -1));
297
453
  return;
298
454
  }
299
455
  if (action === "down") {
300
- setSelectedIndex((i) => clampPickerIndex(i + 1, options.length));
456
+ setSelectedId((id) => moveModelSelection(options, id, 1));
301
457
  return;
302
458
  }
303
459
  if (action === "backspace" || action === "delete") {
304
460
  setQuery((q) => {
305
461
  const next = q.slice(0, -1);
306
- setSelectedIndex(0);
462
+ setSelectedId(undefined);
307
463
  return next;
308
464
  });
309
465
  return;
@@ -311,47 +467,60 @@ export function ModelPicker({ registry, current, currentThinkingLevel, recent, o
311
467
  if (isPrintablePickerInput(input) && !key.ctrl && !key.meta) {
312
468
  setQuery((q) => {
313
469
  const next = q + input;
314
- setSelectedIndex(0);
470
+ setSelectedId(undefined);
315
471
  return next;
316
472
  });
317
473
  return;
318
474
  }
319
475
  });
320
- const safeSelectedIndex = clampPickerIndex(selectedIndex, options.length);
321
- const start = pickerWindowStart(safeSelectedIndex, options.length, bodyRows);
322
- const visible = options.slice(start, start + bodyRows);
476
+ const displayItems = buildModelPickerDisplayItems(options);
477
+ const safeSelectedDisplayIndex = resolveModelPickerDisplayIndex(displayItems, resolvedSelectedId);
478
+ const start = pickerWindowStart(safeSelectedDisplayIndex, displayItems.length, bodyRows);
479
+ const visible = displayItems.slice(start, start + bodyRows);
323
480
  const rawModelRows = options.length === 0
324
481
  ? [{
325
482
  key: "no-results",
326
483
  row: formatNoModelResultsRow(query, rowWidth),
327
484
  selected: false,
485
+ header: false,
328
486
  }]
329
- : visible.map((opt, i) => {
330
- const actualIndex = start + i;
331
- const isSelected = actualIndex === safeSelectedIndex;
487
+ : visible.map((item) => {
488
+ if (item.kind === "header") {
489
+ return {
490
+ key: item.key,
491
+ row: padVisual(truncateVisual(` ${item.label}`, rowWidth), rowWidth),
492
+ selected: false,
493
+ header: true,
494
+ };
495
+ }
496
+ const isSelected = item.option.id === resolvedSelectedId;
332
497
  return {
333
- key: opt.id,
334
- row: formatModelPickerRow(opt, {
498
+ key: item.key,
499
+ row: formatModelPickerRow(item.option, {
335
500
  selected: isSelected,
336
- current: opt.id === current,
501
+ current: item.option.id === current,
337
502
  width: rowWidth,
338
503
  }),
339
504
  selected: isSelected,
505
+ header: false,
340
506
  };
341
507
  });
342
508
  const modelRows = padPickerRows(rawModelRows.map((row) => row.row), bodyRows, rowWidth).map((row, index) => ({
343
509
  key: rawModelRows[index]?.key ?? `blank-${index}`,
344
510
  row,
345
511
  selected: rawModelRows[index]?.selected ?? false,
512
+ header: rawModelRows[index]?.header ?? false,
346
513
  }));
347
- const highlightedOption = options[clampPickerIndex(selectedIndex, options.length)];
348
- const enterAction = highlightedOption && shouldOpenEffortPicker(highlightedOption)
349
- ? (isThinkingToggleOption(highlightedOption) ? "choose mode" : "choose effort")
350
- : "select";
514
+ const highlightedOption = selectedIndex >= 0 ? options[selectedIndex] : undefined;
515
+ const enterAction = highlightedOption
516
+ ? (shouldOpenEffortPicker(highlightedOption)
517
+ ? (isThinkingToggleOption(highlightedOption) ? "choose mode" : "choose effort")
518
+ : "select")
519
+ : "wait";
351
520
  const effortTitle = phase.kind === "effort" && isThinkingToggleOption(phase.model)
352
521
  ? "Select Thinking Mode"
353
522
  : "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))) })] }));
523
+ 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
524
  }
356
525
  function EffortPickerView({ model, selectedIndex, bodyRows, rowWidth, }) {
357
526
  const theme = useTheme();
@@ -388,40 +557,124 @@ function SearchField({ query, placeholder, width }) {
388
557
  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
558
  }
390
559
  export function buildLocalModelOptions(registry, current, recent) {
391
- const enabled = registry.getEnabled();
560
+ const providers = registry.getEnabled().filter((provider) => isUserVisibleProvider(provider.id));
561
+ const localModelsByProvider = new Map(providers.map((provider) => [
562
+ provider.id,
563
+ localModelsForProvider(registry, provider),
564
+ ]));
565
+ return buildMergedModelOptions({
566
+ providers,
567
+ localModelsByProvider,
568
+ discoveries: new Map(),
569
+ current,
570
+ recent,
571
+ });
572
+ }
573
+ export function buildMergedModelOptions(input) {
392
574
  const opts = [];
393
575
  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
- });
576
+ const providerById = new Map(input.providers.map((provider) => [provider.id, provider]));
577
+ const effectiveModelsByProvider = new Map(input.providers.map((provider) => {
578
+ const localModels = input.localModelsByProvider.get(provider.id) ?? [];
579
+ const discovery = input.discoveries.get(provider.id);
580
+ return [provider.id, effectiveProviderModels(localModels, discovery)];
581
+ }));
582
+ const promotedFamilies = new Set();
583
+ const promotedRecentProviderIds = [];
584
+ const familyKeyForId = (id) => {
585
+ const { providerId, modelId } = decodeModel(id);
586
+ const provider = providerId ? providerById.get(providerId) : undefined;
587
+ return provider
588
+ ? modelPickerFamilyKey(provider, modelId)
589
+ : `unknown:${id}`;
590
+ };
591
+ const appendPinned = (id, group) => {
592
+ appendModelOption(opts, seen, pinnedModelOption({
593
+ id,
594
+ group,
595
+ providers: input.providers,
596
+ localModelsByProvider: input.localModelsByProvider,
597
+ discoveries: input.discoveries,
598
+ }));
599
+ };
600
+ const appendFamily = (seedId, group) => {
601
+ const { providerId, modelId } = decodeModel(seedId);
602
+ const provider = providerId ? providerById.get(providerId) : undefined;
603
+ if (!provider) {
604
+ appendPinned(seedId, group);
605
+ return;
606
+ }
607
+ const familyKey = modelPickerFamilyKey(provider, modelId);
608
+ const familyModels = (effectiveModelsByProvider.get(provider.id) ?? [])
609
+ .filter((model) => modelPickerFamilyKey(provider, model.id) === familyKey);
610
+ for (const model of familyModels) {
611
+ appendModelOption(opts, seen, {
612
+ ...modelOptionMetadata(encodeModel(provider.id, model.id), model, true),
613
+ group,
614
+ providerBadge: provider.name,
615
+ available: true,
616
+ pending: false,
617
+ });
618
+ }
619
+ // Keep an exact Current/Recent pin visible even when an authoritative
620
+ // catalog no longer contains it. It remains unavailable and never causes
621
+ // local siblings to be reintroduced.
622
+ if (!familyModels.some((model) => model.id === modelId))
623
+ appendPinned(seedId, group);
624
+ };
625
+ if (input.current) {
626
+ appendPinned(input.current, "Current");
627
+ const currentFamilyKey = familyKeyForId(input.current);
628
+ promotedFamilies.add(currentFamilyKey);
629
+ appendFamily(input.current, "Current");
630
+ }
631
+ let recentFamilyCount = 0;
632
+ for (const recentId of [...new Set(input.recent)]) {
633
+ if (!recentId || recentId === input.current)
634
+ continue;
635
+ const familyKey = familyKeyForId(recentId);
636
+ if (promotedFamilies.has(familyKey)) {
637
+ // A previously promoted family may not contain this exact Recent model
638
+ // in an authoritative catalog. Preserve that pin as unavailable; when
639
+ // it is present, exact-ID deduplication makes this a no-op.
640
+ appendPinned(recentId, "Recent");
641
+ continue;
642
+ }
643
+ promotedFamilies.add(familyKey);
644
+ recentFamilyCount += 1;
645
+ const { providerId } = decodeModel(recentId);
646
+ if (providerId && providerById.has(providerId) && !promotedRecentProviderIds.includes(providerId)) {
647
+ promotedRecentProviderIds.push(providerId);
648
+ }
649
+ appendFamily(recentId, "Recent");
650
+ if (recentFamilyCount >= MODEL_PICKER_RECENT_FAMILY_LIMIT)
651
+ break;
403
652
  }
404
- for (const provider of enabled.filter((item) => isUserVisibleProvider(item.id))) {
405
- for (const model of localModelsForProvider(registry, provider)) {
653
+ const orderedProviderIds = [];
654
+ const addProviderId = (providerId) => {
655
+ if (providerId && providerById.has(providerId) && !orderedProviderIds.includes(providerId)) {
656
+ orderedProviderIds.push(providerId);
657
+ }
658
+ };
659
+ addProviderId(decodeModel(input.current).providerId);
660
+ for (const providerId of promotedRecentProviderIds)
661
+ addProviderId(providerId);
662
+ for (const provider of input.providers)
663
+ addProviderId(provider.id);
664
+ for (const providerId of orderedProviderIds) {
665
+ const provider = providerById.get(providerId);
666
+ if (!provider)
667
+ continue;
668
+ for (const model of effectiveModelsByProvider.get(provider.id) ?? []) {
406
669
  appendModelOption(opts, seen, {
407
- id: encodeModel(model.providerId, model.id),
408
- label: model.name,
670
+ ...modelOptionMetadata(encodeModel(provider.id, model.id), model, true),
409
671
  group: provider.name,
410
672
  providerBadge: provider.name,
673
+ available: true,
674
+ pending: false,
411
675
  });
412
676
  }
413
677
  }
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
678
  return opts;
426
679
  }
427
680
  function localModelsForProvider(registry, provider) {
@@ -435,20 +688,104 @@ function localModelsForProvider(registry, provider) {
435
688
  id: model.id,
436
689
  name: model.name,
437
690
  providerId: provider.id,
691
+ reasoningLevels: model.reasoningLevels,
692
+ defaultReasoningLevel: model.defaultReasoningLevel,
693
+ contextWindow: model.contextWindow,
694
+ toolOutputTokenLimit: model.toolOutputTokenLimit,
438
695
  }));
439
696
  }
440
697
  function appendModelOption(options, seen, option) {
441
698
  if (seen.has(option.id))
442
699
  return;
443
700
  seen.add(option.id);
444
- options.push({
445
- ...option,
446
- reasoningLevels: option.reasoningLevels ?? reasoningLevelsForModel(option.id),
447
- });
701
+ options.push(option);
448
702
  }
449
- function preferredModelIndex(options, current) {
450
- const idx = options.findIndex((option) => option.id === current);
451
- return idx >= 0 ? idx : 0;
703
+ function pinnedModelOption(input) {
704
+ const { providerId, modelId } = decodeModel(input.id);
705
+ const provider = input.providers.find((item) => item.id === providerId);
706
+ if (!provider) {
707
+ return {
708
+ ...modelOptionMetadata(input.id, undefined, false),
709
+ group: input.group,
710
+ providerBadge: providerId || "",
711
+ available: false,
712
+ pending: false,
713
+ };
714
+ }
715
+ const local = input.localModelsByProvider.get(provider.id) ?? [];
716
+ const discovery = input.discoveries.get(provider.id);
717
+ const discoveredModel = discovery?.models.find((model) => model.id === modelId);
718
+ const localModel = local.find((model) => model.id === modelId);
719
+ const metadata = discoveredModel ?? localModel;
720
+ const hasKnownMetadata = !!metadata;
721
+ let available = hasKnownMetadata;
722
+ let pending = false;
723
+ if (!discovery || discovery.status === "pending") {
724
+ pending = !hasKnownMetadata;
725
+ }
726
+ else if (discovery.authoritative) {
727
+ available = !!discoveredModel;
728
+ }
729
+ return {
730
+ ...modelOptionMetadata(input.id, metadata, hasKnownMetadata),
731
+ group: input.group,
732
+ providerBadge: provider.name,
733
+ available,
734
+ pending,
735
+ };
736
+ }
737
+ function effectiveProviderModels(localModels, discovery) {
738
+ if (!discovery)
739
+ return localModels;
740
+ if (discovery.authoritative)
741
+ return discovery.models;
742
+ return discovery.models.length > 0 ? discovery.models : localModels;
743
+ }
744
+ function modelOptionMetadata(id, rawModel, known) {
745
+ const model = rawModel;
746
+ const reasoningLevels = model?.reasoningLevels
747
+ ? [...model.reasoningLevels]
748
+ : (known ? reasoningLevelsForModel(id) : []);
749
+ const defaultReasoningLevel = model?.defaultReasoningLevel
750
+ && reasoningLevels.includes(model.defaultReasoningLevel)
751
+ ? model.defaultReasoningLevel
752
+ : undefined;
753
+ return {
754
+ id,
755
+ label: model?.name || displayModel(id),
756
+ reasoningLevels,
757
+ defaultReasoningLevel,
758
+ contextWindow: model?.contextWindow,
759
+ toolOutputTokenLimit: model?.toolOutputTokenLimit,
760
+ };
761
+ }
762
+ async function discoverModelsForPicker(registry, provider, forceRefresh) {
763
+ return registry.discoverModels(provider, { forceRefresh });
764
+ }
765
+ export function formatModelDiscoveryStatus(providers, discoveries) {
766
+ if (providers.some((provider) => discoveries.get(provider.id)?.status !== "complete")) {
767
+ return { text: "Refreshing model catalog…", hasError: false };
768
+ }
769
+ const failed = providers.filter((provider) => discoveries.get(provider.id)?.error);
770
+ if (failed.length > 0) {
771
+ const names = failed.slice(0, 2).map((provider) => provider.name).join(", ");
772
+ const suffix = failed.length > 2 ? ` +${failed.length - 2}` : "";
773
+ return { text: `Showing fallback models for ${names}${suffix} · Ctrl+R retry`, hasError: true };
774
+ }
775
+ return { text: "", hasError: false };
776
+ }
777
+ function sameModelOptionMetadata(left, right) {
778
+ return left.id === right.id
779
+ && left.label === right.label
780
+ && left.group === right.group
781
+ && left.providerBadge === right.providerBadge
782
+ && left.defaultReasoningLevel === right.defaultReasoningLevel
783
+ && left.contextWindow === right.contextWindow
784
+ && left.toolOutputTokenLimit === right.toolOutputTokenLimit
785
+ && left.available === right.available
786
+ && left.pending === right.pending
787
+ && left.reasoningLevels.length === right.reasoningLevels.length
788
+ && left.reasoningLevels.every((level, index) => level === right.reasoningLevels[index]);
452
789
  }
453
790
  function reasoningLevelsForModel(model) {
454
791
  const { providerId, modelId } = decodeModel(model);
@@ -463,6 +800,16 @@ export function ProviderPicker({ providers, current, onSelect, onCancel, title,
463
800
  const idx = providers.findIndex((p) => p.id === current);
464
801
  return idx >= 0 ? idx : 0;
465
802
  });
803
+ // Authoritative selection lives in a ref, mutated synchronously by the
804
+ // input handler; state only mirrors it for display. Slow ptys (web-terminal
805
+ // bridges) can deliver several key events before React commits a re-render,
806
+ // and a state-only selection would leave the handler closure stale — Enter
807
+ // would then select the pre-batch highlight.
808
+ const selectedIndexRef = useRef(selectedIndex);
809
+ const moveSelection = (next) => {
810
+ selectedIndexRef.current = next;
811
+ setSelectedIndex(next);
812
+ };
466
813
  useEffect(() => {
467
814
  const p = providers[selectedIndex];
468
815
  if (p)
@@ -477,30 +824,31 @@ export function ProviderPicker({ providers, current, onSelect, onCancel, title,
477
824
  return;
478
825
  }
479
826
  if (action === "enter") {
480
- const p = providers[selectedIndex];
827
+ const p = providers[selectedIndexRef.current];
481
828
  if (p)
482
829
  onSelect(p.id);
483
830
  return;
484
831
  }
485
832
  if (action === "up") {
486
- setSelectedIndex((i) => Math.max(0, i - 1));
833
+ moveSelection((selectedIndexRef.current - 1 + providers.length) % providers.length);
487
834
  return;
488
835
  }
489
836
  if (action === "down") {
490
- setSelectedIndex((i) => Math.min(providers.length - 1, i + 1));
837
+ moveSelection((selectedIndexRef.current + 1) % providers.length);
491
838
  return;
492
839
  }
493
840
  if (isPrintablePickerInput(input) && input.length === 1 && /[a-z]/i.test(input)) {
494
841
  const char = input.toLowerCase();
495
- for (let i = selectedIndex + 1; i < providers.length; i++) {
842
+ const from = selectedIndexRef.current;
843
+ for (let i = from + 1; i < providers.length; i++) {
496
844
  if (providers[i].name.toLowerCase().startsWith(char)) {
497
- setSelectedIndex(i);
845
+ moveSelection(i);
498
846
  return;
499
847
  }
500
848
  }
501
- for (let i = 0; i <= selectedIndex; i++) {
849
+ for (let i = 0; i <= from; i++) {
502
850
  if (providers[i].name.toLowerCase().startsWith(char)) {
503
- setSelectedIndex(i);
851
+ moveSelection(i);
504
852
  return;
505
853
  }
506
854
  }
@@ -508,7 +856,12 @@ export function ProviderPicker({ providers, current, onSelect, onCancel, title,
508
856
  });
509
857
  const start = Math.max(0, Math.min(selectedIndex, providers.length - maxVisible));
510
858
  const visible = providers.slice(start, start + maxVisible);
511
- return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, children: [_jsx(Text, { bold: true, color: theme.accent, children: title || "Select Provider" }), _jsx(Text, { color: theme.muted, children: "\u2191/\u2193 navigate \u00B7 Enter select \u00B7 Esc cancel \u00B7 type letter to jump" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: visible.map((p, i) => {
859
+ return (
860
+ // backgroundColor lives on the picker's OWN root box, below its state:
861
+ // a canvas toggle (theme preview across a forced-theme mismatch) then
862
+ // reshapes only this stateless output subtree. On an ancestor wrapper it
863
+ // would remount the whole picker and reset selectedIndex mid-navigation.
864
+ _jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, backgroundColor: theme.background, children: [_jsx(Text, { bold: true, color: theme.accent, children: title || "Select Provider" }), _jsx(Text, { color: theme.muted, children: "\u2191/\u2193 navigate \u00B7 Enter select \u00B7 Esc cancel \u00B7 type letter to jump" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: visible.map((p, i) => {
512
865
  const actualIndex = start + i;
513
866
  const isSelected = actualIndex === selectedIndex;
514
867
  return (_jsx(Box, { children: _jsxs(Text, { color: isSelected ? theme.accent : undefined, children: [isSelected ? "> " : " ", p.name, p.id === current ? " (current)" : "", !p.enabled ? " [disabled]" : ""] }) }, p.id));