ubume 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/README.md +88 -0
  2. package/bin/codexa-local-harness-bridge.js +3 -0
  3. package/bin/codexa.js +28 -0
  4. package/bin/ubume-local-harness-bridge.js +325 -0
  5. package/bin/ubume.js +398 -0
  6. package/package.json +66 -0
  7. package/src/app.tsx +5759 -0
  8. package/src/commands/handler.ts +889 -0
  9. package/src/config/appVersion.ts +69 -0
  10. package/src/config/buildInfo.ts +3 -0
  11. package/src/config/launchArgs.ts +196 -0
  12. package/src/config/layeredConfig.ts +853 -0
  13. package/src/config/legacyEnv.ts +16 -0
  14. package/src/config/persistence.ts +377 -0
  15. package/src/config/runtimeConfig.ts +558 -0
  16. package/src/config/settings.ts +405 -0
  17. package/src/config/toml-serialize.ts +109 -0
  18. package/src/config/trustStore.ts +84 -0
  19. package/src/config/updateCheckCache.ts +85 -0
  20. package/src/core/README.md +55 -0
  21. package/src/core/agent/loop.ts +464 -0
  22. package/src/core/agent/protocol.ts +345 -0
  23. package/src/core/agent/tools.ts +423 -0
  24. package/src/core/auth/codexAuth.ts +359 -0
  25. package/src/core/codex/codexExecArgs.ts +166 -0
  26. package/src/core/codex/codexLaunch.ts +163 -0
  27. package/src/core/codex/codexPrompt.ts +429 -0
  28. package/src/core/debug/inputDebug.ts +51 -0
  29. package/src/core/debug/localStreamDebug.ts +50 -0
  30. package/src/core/debug/modelStateDebug.ts +35 -0
  31. package/src/core/executables/antigravityExecutable.ts +48 -0
  32. package/src/core/executables/claudeExecutable.ts +63 -0
  33. package/src/core/executables/codexExecutable.ts +160 -0
  34. package/src/core/executables/executableResolver.ts +164 -0
  35. package/src/core/executables/geminiExecutable.ts +78 -0
  36. package/src/core/models/codexCapabilities.ts +97 -0
  37. package/src/core/models/codexModelCapabilities.ts +624 -0
  38. package/src/core/models/codexModelsCacheSeed.ts +153 -0
  39. package/src/core/models/modelSpecs.ts +303 -0
  40. package/src/core/models/providerModelCache.ts +94 -0
  41. package/src/core/perf/profiler.ts +125 -0
  42. package/src/core/perf/renderDebug.ts +398 -0
  43. package/src/core/process/CommandRunner.ts +280 -0
  44. package/src/core/process/processValidation.ts +111 -0
  45. package/src/core/providerLauncher/launcher.ts +220 -0
  46. package/src/core/providerLauncher/registry.ts +354 -0
  47. package/src/core/providerLauncher/types.ts +95 -0
  48. package/src/core/providerLauncher/workspaceConfig.ts +487 -0
  49. package/src/core/providerRuntime/anthropic.ts +580 -0
  50. package/src/core/providerRuntime/antigravity.ts +500 -0
  51. package/src/core/providerRuntime/capabilityProfile.ts +383 -0
  52. package/src/core/providerRuntime/claudeCodeDiscovery.ts +724 -0
  53. package/src/core/providerRuntime/claudeCodeDiscoveryDebug.ts +55 -0
  54. package/src/core/providerRuntime/codexaCupy.ts +97 -0
  55. package/src/core/providerRuntime/codexaNative.ts +425 -0
  56. package/src/core/providerRuntime/contextMetadata.ts +397 -0
  57. package/src/core/providerRuntime/gemini.ts +789 -0
  58. package/src/core/providerRuntime/lmstudio.ts +118 -0
  59. package/src/core/providerRuntime/local.ts +770 -0
  60. package/src/core/providerRuntime/localHarness/runtime.ts +1090 -0
  61. package/src/core/providerRuntime/localOutputBudget.ts +17 -0
  62. package/src/core/providerRuntime/mistralVibe.ts +667 -0
  63. package/src/core/providerRuntime/models.ts +175 -0
  64. package/src/core/providerRuntime/reasoning.ts +20 -0
  65. package/src/core/providerRuntime/registry.ts +286 -0
  66. package/src/core/providerRuntime/types.ts +142 -0
  67. package/src/core/providerRuntime/unsloth.ts +216 -0
  68. package/src/core/providers/codexJsonStream.ts +305 -0
  69. package/src/core/providers/codexSubprocess.ts +378 -0
  70. package/src/core/providers/codexTranscript.ts +695 -0
  71. package/src/core/providers/openaiNative.ts +13 -0
  72. package/src/core/providers/registry.ts +21 -0
  73. package/src/core/providers/types.ts +91 -0
  74. package/src/core/shared/attachments.ts +101 -0
  75. package/src/core/shared/cleanupFastFail.ts +67 -0
  76. package/src/core/shared/clipboard.ts +24 -0
  77. package/src/core/shared/clipboardImage.ts +111 -0
  78. package/src/core/shared/githubDiagnostics.ts +222 -0
  79. package/src/core/shared/hollowResponseFormat.ts +39 -0
  80. package/src/core/terminal/clearFrameBoundary.ts +852 -0
  81. package/src/core/terminal/frameLock.ts +110 -0
  82. package/src/core/terminal/inkRenderReset.ts +123 -0
  83. package/src/core/terminal/startupClear.ts +20 -0
  84. package/src/core/terminal/terminalCapabilities.ts +100 -0
  85. package/src/core/terminal/terminalControl.ts +169 -0
  86. package/src/core/terminal/terminalSanitize.ts +147 -0
  87. package/src/core/terminal/terminalTitle.ts +400 -0
  88. package/src/core/version/channel.ts +27 -0
  89. package/src/core/version/packageManager.ts +119 -0
  90. package/src/core/version/updateCheck.ts +203 -0
  91. package/src/core/workspace/appData.ts +107 -0
  92. package/src/core/workspace/conversationStore.ts +335 -0
  93. package/src/core/workspace/launchContext.ts +259 -0
  94. package/src/core/workspace/planStorage.ts +135 -0
  95. package/src/core/workspace/projectInstructions.ts +54 -0
  96. package/src/core/workspace/scratchDir.ts +64 -0
  97. package/src/core/workspace/workspaceActivity.ts +384 -0
  98. package/src/core/workspace/workspaceGuard.ts +377 -0
  99. package/src/core/workspace/workspaceRoot.ts +47 -0
  100. package/src/exec.ts +73 -0
  101. package/src/headless/execArgs.ts +296 -0
  102. package/src/headless/execRunner.ts +304 -0
  103. package/src/index.tsx +270 -0
  104. package/src/legacyEnvBootstrap.ts +5 -0
  105. package/src/session/appSession.ts +771 -0
  106. package/src/session/chatLifecycle.ts +994 -0
  107. package/src/session/conversation.ts +107 -0
  108. package/src/session/liveRenderScheduler.ts +214 -0
  109. package/src/session/persistedResponse.ts +93 -0
  110. package/src/session/planFlow.ts +159 -0
  111. package/src/session/planTranscript.ts +19 -0
  112. package/src/session/promptRunSchedule.ts +26 -0
  113. package/src/session/types.ts +234 -0
  114. package/src/test/runtimeTestUtils.ts +14 -0
  115. package/src/types/react-dom.d.ts +3 -0
  116. package/src/ui/chrome/ActivityBars.tsx +68 -0
  117. package/src/ui/chrome/ActivityIndicator.tsx +58 -0
  118. package/src/ui/chrome/AnimatedStatusText.tsx +69 -0
  119. package/src/ui/chrome/AppShell.tsx +474 -0
  120. package/src/ui/chrome/BottomComposer.tsx +1135 -0
  121. package/src/ui/chrome/DashCard.tsx +82 -0
  122. package/src/ui/chrome/RunFooter.tsx +65 -0
  123. package/src/ui/chrome/RuntimeStatusBar.tsx +108 -0
  124. package/src/ui/chrome/Spinner.tsx +25 -0
  125. package/src/ui/chrome/TopHeader.tsx +439 -0
  126. package/src/ui/chrome/UpdateAvailableCard.tsx +42 -0
  127. package/src/ui/chrome/busyStatusAnimation.ts +11 -0
  128. package/src/ui/input/commandNormalize.ts +66 -0
  129. package/src/ui/input/focus.ts +73 -0
  130. package/src/ui/input/imageAttachments.ts +18 -0
  131. package/src/ui/input/inputBuffer.ts +203 -0
  132. package/src/ui/input/pastedContent.ts +75 -0
  133. package/src/ui/input/rawArrowKeys.ts +28 -0
  134. package/src/ui/input/slashCommands.ts +43 -0
  135. package/src/ui/input/useStdinRawModeLease.ts +23 -0
  136. package/src/ui/layout.ts +560 -0
  137. package/src/ui/panels/AttachmentImportPanel.tsx +131 -0
  138. package/src/ui/panels/AuthPanel.tsx +149 -0
  139. package/src/ui/panels/BackendPicker.tsx +28 -0
  140. package/src/ui/panels/ModePicker.tsx +31 -0
  141. package/src/ui/panels/ModelPicker.tsx +31 -0
  142. package/src/ui/panels/ModelPickerScreen.tsx +761 -0
  143. package/src/ui/panels/ModelReasoningPicker.tsx +458 -0
  144. package/src/ui/panels/Panel.tsx +51 -0
  145. package/src/ui/panels/PermissionsPanel.tsx +78 -0
  146. package/src/ui/panels/PlanActionPicker.tsx +187 -0
  147. package/src/ui/panels/ProviderPicker.tsx +753 -0
  148. package/src/ui/panels/ProviderSetupPrompt.tsx +52 -0
  149. package/src/ui/panels/ReasoningPicker.tsx +46 -0
  150. package/src/ui/panels/ResumePicker.tsx +90 -0
  151. package/src/ui/panels/SelectionPanel.tsx +138 -0
  152. package/src/ui/panels/SettingsPanel.tsx +156 -0
  153. package/src/ui/panels/TextEntryPanel.tsx +139 -0
  154. package/src/ui/panels/ThemePicker.tsx +32 -0
  155. package/src/ui/panels/ToolApprovalPanel.tsx +46 -0
  156. package/src/ui/panels/UpdatePromptPanel.tsx +236 -0
  157. package/src/ui/panels/responsivePickerViewport.ts +64 -0
  158. package/src/ui/render/Markdown.tsx +331 -0
  159. package/src/ui/render/diffRenderer.ts +116 -0
  160. package/src/ui/render/logoVariants.ts +113 -0
  161. package/src/ui/render/modeDisplay.ts +52 -0
  162. package/src/ui/render/outputPipeline.ts +64 -0
  163. package/src/ui/render/runtimeDisplay.ts +128 -0
  164. package/src/ui/render/terminalAnswerFormat.ts +128 -0
  165. package/src/ui/render/textLayout.ts +392 -0
  166. package/src/ui/theme.tsx +274 -0
  167. package/src/ui/themeFlow.ts +41 -0
  168. package/src/ui/timeline/ActionRequiredBlock.tsx +38 -0
  169. package/src/ui/timeline/AgentBlock.tsx +130 -0
  170. package/src/ui/timeline/StaticIntroItem.tsx +54 -0
  171. package/src/ui/timeline/ThinkingBlock.tsx +100 -0
  172. package/src/ui/timeline/Timeline.tsx +1410 -0
  173. package/src/ui/timeline/TranscriptShell.tsx +302 -0
  174. package/src/ui/timeline/TurnGroup.tsx +673 -0
  175. package/src/ui/timeline/layoutListWindow.ts +145 -0
  176. package/src/ui/timeline/liveViewportWindow.ts +68 -0
  177. package/src/ui/timeline/progressEntries.ts +156 -0
  178. package/src/ui/timeline/runActivityView.ts +37 -0
  179. package/src/ui/timeline/staticTranscriptCache.ts +174 -0
  180. package/src/ui/timeline/streamCoalesce.ts +53 -0
  181. package/src/ui/timeline/timelineMeasure.ts +3273 -0
  182. package/src/ui/useThrottledValue.ts +31 -0
@@ -0,0 +1,761 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { Box, Text, useFocus, useInput } from "ink";
3
+ import {
4
+ type CodexModelCapability,
5
+ type ReasoningEffortCapability,
6
+ normalizeReasoningForModelCapabilities,
7
+ } from "../../core/models/codexModelCapabilities.js";
8
+ import { formatReasoningLabel } from "../../config/settings.js";
9
+ import { traceInputDebug } from "../../core/debug/inputDebug.js";
10
+ import { FOCUS_IDS } from "../input/focus.js";
11
+ import {
12
+ clampVisualText,
13
+ getShellWidth,
14
+ getAvailableRowsForPanel,
15
+ useAppLayoutBudget,
16
+ usePanelAvailableRows,
17
+ type Layout,
18
+ useActivePanelLayout,
19
+ type ActivePanelLayout,
20
+ type PanelLayout,
21
+ usePanelLayout,
22
+ } from "../layout.js";
23
+ import { calculateResponsivePickerViewport } from "./responsivePickerViewport.js";
24
+ import { useTheme } from "../theme.js";
25
+ import type { GeminiModelSelection } from "../../core/providerRuntime/types.js";
26
+
27
+ // ─── Types & helpers ─────────────────────────────────────────────────────────
28
+
29
+ type ModelPickerCloseReason = "escape" | "empty-selection";
30
+ type ModelRenderMode = "full" | "compact" | "windowed";
31
+
32
+ interface ModelPickerScreenProps {
33
+ layout: Layout & {
34
+ contentWidth?: number;
35
+ };
36
+ availableRows?: number;
37
+ activePanelLayout?: ActivePanelLayout;
38
+ panelLayout?: PanelLayout;
39
+ models: readonly CodexModelCapability[];
40
+ currentModel: string;
41
+ currentReasoning: string;
42
+ currentGeminiSelection?: GeminiModelSelection;
43
+ activeProviderLabel?: string;
44
+ isLoading?: boolean;
45
+ emptyMessage?: string;
46
+ routeTextOverride?: string;
47
+ onSelect: (model: string, reasoning: string, geminiSelection?: GeminiModelSelection) => void;
48
+ onCancel: (reason?: ModelPickerCloseReason) => void;
49
+ }
50
+
51
+ function getInitialCursor(models: readonly CodexModelCapability[], currentModel: string, currentGeminiSelection?: GeminiModelSelection): number {
52
+ if (currentGeminiSelection?.kind === "auto") {
53
+ const familyId = currentGeminiSelection.family === "gemini-3" ? "auto-gemini-3" : "auto-gemini-2.5";
54
+ const index = models.findIndex((m) => m.id === familyId);
55
+ if (index >= 0) return index;
56
+ }
57
+ const index = models.findIndex((model) =>
58
+ model.model === currentModel || model.id === currentModel || getVariantModelIds(model).includes(currentModel));
59
+ return Math.max(0, index);
60
+ }
61
+
62
+ function getModelName(model: CodexModelCapability): string {
63
+ return model.label === model.model ? model.model : `${model.label} (${model.model})`;
64
+ }
65
+
66
+ function getReasoningLevels(model: CodexModelCapability | undefined): readonly ReasoningEffortCapability[] {
67
+ return model?.supportedReasoningLevels ?? [];
68
+ }
69
+
70
+ const GEMINI_EFFORT_IDS = new Set(["low", "medium", "high", "xhigh", "max"]);
71
+
72
+ function collapseGeminiEffortVariants(models: readonly CodexModelCapability[]): readonly CodexModelCapability[] {
73
+ const groups = new Map<string, CodexModelCapability>();
74
+ const variantIds = new Map<string, string[]>();
75
+ const variantLevels = new Map<string, Set<string>>();
76
+
77
+ for (const model of models) {
78
+ const match = model.model.match(/^(.*?)-(low|medium|high|xhigh|max)$/i);
79
+ if (!match || !GEMINI_EFFORT_IDS.has(match[2]!.toLowerCase())) {
80
+ groups.set(model.id, model);
81
+ continue;
82
+ }
83
+
84
+ const familyId = match[1]!;
85
+ const existing = groups.get(familyId);
86
+ const level = match[2]!.toLowerCase();
87
+ const ids = variantIds.get(familyId) ?? [];
88
+ ids.push(model.model);
89
+ variantIds.set(familyId, ids);
90
+ const levels = variantLevels.get(familyId) ?? new Set<string>();
91
+ levels.add(level);
92
+ variantLevels.set(familyId, levels);
93
+
94
+ const label = model.label.replace(/\s*\((?:low|medium|high|xhigh|max)\)\s*$/i, "");
95
+ if (!existing) {
96
+ groups.set(familyId, {
97
+ ...model,
98
+ id: familyId,
99
+ model: familyId,
100
+ label,
101
+ description: `Select the intelligence level for ${label}.`,
102
+ defaultReasoningLevel: level,
103
+ supportedReasoningLevels: [{ id: level, label: formatReasoningLabel(level), description: null }],
104
+ reasoningLevelCount: 1,
105
+ raw: { ...(model.raw && typeof model.raw === "object" ? model.raw : {}), variantIds: ids },
106
+ });
107
+ } else {
108
+ const orderedLevels = ["low", "medium", "high", "xhigh", "max"].filter((id) => levels.has(id));
109
+ groups.set(familyId, {
110
+ ...existing,
111
+ supportedReasoningLevels: orderedLevels.map((id) => ({ id, label: formatReasoningLabel(id), description: null })),
112
+ reasoningLevelCount: orderedLevels.length,
113
+ raw: { ...(existing.raw && typeof existing.raw === "object" ? existing.raw : {}), variantIds: ids },
114
+ });
115
+ }
116
+ }
117
+
118
+ return [...groups.values()].map((model) => {
119
+ const ids = variantIds.get(model.model);
120
+ if (!ids) return model;
121
+ return {
122
+ ...model,
123
+ raw: { ...(model.raw && typeof model.raw === "object" ? model.raw : {}), variantIds: ids },
124
+ };
125
+ });
126
+ }
127
+
128
+ function getVariantModelIds(model: CodexModelCapability): readonly string[] {
129
+ const raw = model.raw;
130
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
131
+ const ids = (raw as { variantIds?: unknown }).variantIds;
132
+ return Array.isArray(ids) ? ids.filter((id): id is string => typeof id === "string") : [];
133
+ }
134
+
135
+ function getVariantReasoning(model: CodexModelCapability | undefined, modelId: string): string | null {
136
+ if (!model) return null;
137
+ const variant = getVariantModelIds(model).find((id) => id === modelId);
138
+ const match = variant?.match(/-(low|medium|high|xhigh|max)$/i);
139
+ return match?.[1]?.toLowerCase() ?? null;
140
+ }
141
+
142
+ function resolveVariantModelId(model: CodexModelCapability, reasoning: string): string {
143
+ const variantIds = getVariantModelIds(model);
144
+ return variantIds.find((id) => id.endsWith(`-${reasoning.toLowerCase()}`)) ?? model.model;
145
+ }
146
+
147
+ function getModelSourceMarker(models: readonly CodexModelCapability[], activeProviderLabel: string): string | null {
148
+ if (activeProviderLabel !== "Claude" || models.length === 0) return null;
149
+ const raw = models[0]?.raw as { source?: string; discoveryKind?: string } | null | undefined;
150
+ const source = raw?.source;
151
+ const sourceLabel = source === "claude-code-package"
152
+ ? "installed package metadata"
153
+ : source === "claude-code-command"
154
+ ? "Claude Code command"
155
+ : source === "claude-code-cache"
156
+ ? "Claude Code cache"
157
+ : source === "claude-code-config" || source === "settings" || source === "config"
158
+ ? "Claude settings"
159
+ : "Claude Code";
160
+ if (source === "claude-code-package" || source === "claude-code-command" || source === "claude-code-cache" || source === "claude-code-config" || source === "claude-code" || source === "discovered") {
161
+ return raw?.discoveryKind === "aliases"
162
+ ? `Claude Code aliases resolved from ${sourceLabel}`
163
+ : `Claude Code models discovered from ${sourceLabel}`;
164
+ }
165
+ if (source === "settings" || source === "config") return "Claude Code models discovered from Claude settings";
166
+ if (source === "fallback") return "Claude Code discovery failed; using fallback aliases";
167
+ return null;
168
+ }
169
+
170
+ function normalizeDraftReasoning(
171
+ model: CodexModelCapability | undefined,
172
+ reasoning: string,
173
+ ): string {
174
+ if (!model) return reasoning;
175
+ return normalizeReasoningForModelCapabilities(
176
+ model.model,
177
+ reasoning,
178
+ {
179
+ status: "ready",
180
+ source: model.source,
181
+ models: [model],
182
+ discoveredAt: Date.now(),
183
+ executable: null,
184
+ error: null,
185
+ },
186
+ );
187
+ }
188
+
189
+ function getReasoningIndex(levels: readonly ReasoningEffortCapability[], reasoning: string): number {
190
+ return Math.max(0, levels.findIndex((level) => level.id === reasoning));
191
+ }
192
+
193
+ function describeInputKey(
194
+ input: string,
195
+ key: {
196
+ escape?: boolean;
197
+ return?: boolean;
198
+ upArrow?: boolean;
199
+ downArrow?: boolean;
200
+ leftArrow?: boolean;
201
+ rightArrow?: boolean;
202
+ ctrl?: boolean;
203
+ meta?: boolean;
204
+ },
205
+ ) {
206
+ return {
207
+ input,
208
+ escape: Boolean(key.escape),
209
+ return: Boolean(key.return),
210
+ upArrow: Boolean(key.upArrow),
211
+ downArrow: Boolean(key.downArrow),
212
+ leftArrow: Boolean(key.leftArrow),
213
+ rightArrow: Boolean(key.rightArrow),
214
+ ctrl: Boolean(key.ctrl),
215
+ meta: Boolean(key.meta),
216
+ };
217
+ }
218
+
219
+ // ─── Component ────────────────────────────────────────────────────────────────
220
+
221
+ export function ModelPickerScreen({
222
+ layout,
223
+ availableRows: propAvailableRows,
224
+ activePanelLayout,
225
+ panelLayout,
226
+ models: baseModels,
227
+ currentModel,
228
+ currentReasoning,
229
+ currentGeminiSelection,
230
+ activeProviderLabel = "OpenAI",
231
+ isLoading = false,
232
+ emptyMessage,
233
+ routeTextOverride,
234
+ onSelect,
235
+ onCancel,
236
+ }: ModelPickerScreenProps) {
237
+ const theme = useTheme();
238
+ const isGoogle = activeProviderLabel === "Google";
239
+ const isAntigravity = activeProviderLabel.toLowerCase().includes("antigravity");
240
+
241
+ const models = useMemo(() => {
242
+ const collapsedModels = isAntigravity ? collapseGeminiEffortVariants(baseModels) : baseModels;
243
+ if (!isGoogle) return collapsedModels;
244
+
245
+ const autoModels: CodexModelCapability[] = [
246
+ {
247
+ id: "auto-gemini-3",
248
+ model: "gemini-3-flash-preview",
249
+ label: "Auto (Gemini 3)",
250
+ description: "Best available verified Gemini 3 model.",
251
+ available: true,
252
+ hidden: false,
253
+ isDefault: false,
254
+ defaultReasoningLevel: "medium",
255
+ supportedReasoningLevels: null,
256
+ reasoningLevelCount: null,
257
+ source: "fallback",
258
+ raw: { kind: "auto", family: "gemini-3" },
259
+ },
260
+ {
261
+ id: "auto-gemini-2.5",
262
+ model: "gemini-2.5-pro",
263
+ label: "Auto (Gemini 2.5)",
264
+ description: "Best available Gemini 2.5 model.",
265
+ available: true,
266
+ hidden: false,
267
+ isDefault: false,
268
+ defaultReasoningLevel: "high",
269
+ supportedReasoningLevels: null,
270
+ reasoningLevelCount: null,
271
+ source: "fallback",
272
+ raw: { kind: "auto", family: "gemini-2.5" },
273
+ },
274
+ ];
275
+
276
+ const manualModels = collapsedModels.map((m) => ({
277
+ ...m,
278
+ label: `Manual: ${m.label}`,
279
+ raw: { kind: "manual", modelId: m.model },
280
+ }));
281
+
282
+ return [...autoModels, ...manualModels];
283
+ }, [baseModels, isAntigravity, isGoogle]);
284
+
285
+ const { isFocused } = useFocus({ id: FOCUS_IDS.modelPicker, autoFocus: true });
286
+ const initialModelIndex = getInitialCursor(models, currentModel, currentGeminiSelection);
287
+ const [draftSelectedModel, setDraftSelectedModel] = useState(initialModelIndex);
288
+ const [draftReasoning, setDraftReasoning] = useState(() => normalizeDraftReasoning(
289
+ models[initialModelIndex],
290
+ getVariantReasoning(models[initialModelIndex], currentModel) ?? currentReasoning,
291
+ ));
292
+ const [scrollOffset, setScrollOffset] = useState(0);
293
+
294
+ const selectedModel = models[draftSelectedModel];
295
+ const selectedReasoningLevels = getReasoningLevels(selectedModel);
296
+ const reasoningUnavailable = selectedReasoningLevels.length === 0;
297
+
298
+ useEffect(() => {
299
+ traceInputDebug("model_picker_panel_mounted", {
300
+ focusTarget: FOCUS_IDS.modelPicker,
301
+ modelCount: models.length,
302
+ isLoading,
303
+ });
304
+ return () => {
305
+ traceInputDebug("model_picker_panel_unmounted", {
306
+ focusTarget: FOCUS_IDS.modelPicker,
307
+ });
308
+ };
309
+ }, []);
310
+
311
+ useEffect(() => {
312
+ if (models.length === 0) {
313
+ setDraftSelectedModel(0);
314
+ setDraftReasoning(currentReasoning);
315
+ return;
316
+ }
317
+
318
+ setDraftSelectedModel((current) => {
319
+ const nextCursor = Math.min(Math.max(0, current), models.length - 1);
320
+ const nextModel = models[nextCursor];
321
+ setDraftReasoning((reasoning) => normalizeDraftReasoning(
322
+ nextModel,
323
+ getVariantReasoning(nextModel, currentModel) ?? reasoning,
324
+ ));
325
+ return nextCursor;
326
+ });
327
+ }, [currentModel, currentReasoning, models]);
328
+
329
+ const moveModel = (direction: -1 | 1) => {
330
+ setDraftSelectedModel((current) => {
331
+ const next = Math.max(0, Math.min(models.length - 1, current + direction));
332
+ const nextModel = models[next];
333
+ setDraftReasoning((reasoning) => normalizeDraftReasoning(nextModel, reasoning));
334
+ return next;
335
+ });
336
+ };
337
+
338
+ const selectModelIndex = (index: number) => {
339
+ setDraftSelectedModel(() => {
340
+ const next = Math.max(0, Math.min(models.length - 1, index));
341
+ setDraftReasoning((reasoning) => normalizeDraftReasoning(models[next], reasoning));
342
+ return next;
343
+ });
344
+ };
345
+
346
+ const moveReasoning = (direction: -1 | 1) => {
347
+ const levels = getReasoningLevels(models[draftSelectedModel]);
348
+ if (levels.length <= 1) return;
349
+
350
+ setDraftReasoning((current) => {
351
+ const currentIndex = getReasoningIndex(levels, current);
352
+ const nextIndex = Math.max(0, Math.min(levels.length - 1, currentIndex + direction));
353
+ return levels[nextIndex]?.id ?? current;
354
+ });
355
+ };
356
+
357
+ useInput(
358
+ (input, key) => {
359
+ traceInputDebug("model_picker_panel_input", {
360
+ handler: "ModelPickerScreen.useInput",
361
+ key: describeInputKey(input, key),
362
+ isFocused,
363
+ isLoading,
364
+ modelCount: models.length,
365
+ cursor: draftSelectedModel,
366
+ draftReasoning,
367
+ });
368
+
369
+ if (key.ctrl && (input === "c" || input === "q")) {
370
+ onCancel("escape");
371
+ return;
372
+ }
373
+
374
+ if (key.escape) {
375
+ onCancel("escape");
376
+ return;
377
+ }
378
+
379
+ if (key.return) {
380
+ const model = models[draftSelectedModel];
381
+ if (!model) {
382
+ onCancel("empty-selection");
383
+ return;
384
+ }
385
+ const geminiSelection = isGoogle ? (model.raw as GeminiModelSelection) : undefined;
386
+ const normalizedReasoning = normalizeDraftReasoning(model, draftReasoning);
387
+ onSelect(resolveVariantModelId(model, normalizedReasoning), normalizedReasoning, geminiSelection);
388
+ return;
389
+ }
390
+
391
+ if (key.upArrow || input === "k") {
392
+ moveModel(-1);
393
+ return;
394
+ }
395
+
396
+ if (key.downArrow || input === "j") {
397
+ moveModel(1);
398
+ return;
399
+ }
400
+
401
+ if (key.home) {
402
+ selectModelIndex(0);
403
+ return;
404
+ }
405
+
406
+ if (key.end) {
407
+ selectModelIndex(models.length - 1);
408
+ return;
409
+ }
410
+
411
+ if (key.pageUp) {
412
+ selectModelIndex(draftSelectedModel - Math.max(1, windowResult.capacity));
413
+ return;
414
+ }
415
+
416
+ if (key.pageDown) {
417
+ selectModelIndex(draftSelectedModel + Math.max(1, windowResult.capacity));
418
+ return;
419
+ }
420
+
421
+ if (key.leftArrow || input === "h") {
422
+ moveReasoning(-1);
423
+ return;
424
+ }
425
+
426
+ if (key.rightArrow || input === "l") {
427
+ moveReasoning(1);
428
+ }
429
+ },
430
+ { isActive: isFocused },
431
+ );
432
+
433
+ const contextLayout = useActivePanelLayout();
434
+ const activeLayout = (activePanelLayout ?? contextLayout) as ActivePanelLayout | undefined;
435
+
436
+ const shellWidth = getShellWidth(layout.cols);
437
+ const hookPanelLayout = usePanelLayout();
438
+ const hookAvailableRows = usePanelAvailableRows();
439
+ const resolvedPanelLayout = useMemo<PanelLayout>(() => {
440
+ if (panelLayout) return panelLayout;
441
+ if (hookPanelLayout) return hookPanelLayout;
442
+
443
+ const mode = layout.mode;
444
+ const resolvedRows = activeLayout
445
+ ? activeLayout.availableRows
446
+ : getAvailableRowsForPanel(layout, propAvailableRows ?? hookAvailableRows);
447
+ const resolvedCols = activeLayout
448
+ ? activeLayout.availableCols
449
+ : Math.max(20, shellWidth - 4);
450
+
451
+ return {
452
+ mode: (mode === "compact" || mode === "micro" as any) ? "compact" : mode === "expanded" || mode === "max" as any || mode === "wide" as any ? "expanded" : "regular",
453
+ availableRows: resolvedRows,
454
+ availableCols: resolvedCols,
455
+ };
456
+ }, [panelLayout, hookPanelLayout, layout, activeLayout, propAvailableRows, shellWidth, hookAvailableRows]);
457
+
458
+ const panelWidth = activeLayout
459
+ ? activeLayout.width
460
+ : Math.max(38, Math.min((layout as any).contentWidth ?? shellWidth, shellWidth - 2));
461
+
462
+ const availableRows = resolvedPanelLayout.availableRows;
463
+ const innerWidth = Math.max(1, Math.min(resolvedPanelLayout.availableCols, panelWidth - 4));
464
+ const help = resolvedPanelLayout.mode === "compact"
465
+ ? "↑↓ model · ←→ intelligence · Enter · Esc"
466
+ : "↑↓ model · ←→ reasoning · Enter select · Esc cancel";
467
+ const aOrAn = /^[aeiou]/i.test(activeProviderLabel) ? "an" : "a";
468
+ const routeText = routeTextOverride ?? `Choose ${aOrAn} ${activeProviderLabel} model to use inside Ubume.`;
469
+ const sourceMarker = getModelSourceMarker(models, activeProviderLabel);
470
+
471
+ const appLayoutBudget = useAppLayoutBudget();
472
+
473
+ const activeModelIndex = models.findIndex((model) =>
474
+ model.model === currentModel || model.id === currentModel || getVariantModelIds(model).includes(currentModel));
475
+ const hasSourceMarker = !!sourceMarker;
476
+
477
+ // ─── Layout & Windowing ───────────────────────────────────────────────────
478
+
479
+ const windowResult = useMemo(() => {
480
+ const allowFull = appLayoutBudget?.showPanelColumnHeaders ?? true;
481
+
482
+ // availableRows is already the shell's border-safe panel body. Only rows
483
+ // rendered inside that body belong in this calculation.
484
+ const fullChrome = 5 + (hasSourceMarker ? 1 : 0);
485
+ if (allowFull && models.length + fullChrome <= availableRows) {
486
+ return {
487
+ ...calculateResponsivePickerViewport({ itemCount: models.length, selectedIndex: draftSelectedModel, availableRows, chromeRows: fullChrome, scrollOffset }),
488
+ mode: "full" as const,
489
+ showCurrentLine: false,
490
+ showRouteText: true,
491
+ showReasoningText: true,
492
+ showSourceMarker: hasSourceMarker,
493
+ };
494
+ }
495
+
496
+ // Try fitting without source marker
497
+ if (allowFull && models.length + 5 <= availableRows) {
498
+ return {
499
+ ...calculateResponsivePickerViewport({ itemCount: models.length, selectedIndex: draftSelectedModel, availableRows, chromeRows: 3, scrollOffset }),
500
+ mode: "full" as const,
501
+ showCurrentLine: false,
502
+ showRouteText: true,
503
+ showReasoningText: true,
504
+ showSourceMarker: false,
505
+ };
506
+ }
507
+
508
+ // Try compact with the dedicated intelligence control
509
+ if (models.length + 4 <= availableRows) {
510
+ return {
511
+ ...calculateResponsivePickerViewport({ itemCount: models.length, selectedIndex: draftSelectedModel, availableRows, chromeRows: 2, scrollOffset }),
512
+ mode: "compact" as const,
513
+ showCurrentLine: false,
514
+ showRouteText: false,
515
+ showReasoningText: true,
516
+ showSourceMarker: false,
517
+ };
518
+ }
519
+
520
+ // Try minimal compact
521
+ if (models.length + 2 <= availableRows) {
522
+ return {
523
+ ...calculateResponsivePickerViewport({ itemCount: models.length, selectedIndex: draftSelectedModel, availableRows, chromeRows: 1, scrollOffset }),
524
+ mode: "compact" as const,
525
+ showCurrentLine: false,
526
+ showRouteText: false,
527
+ showReasoningText: false,
528
+ showSourceMarker: false,
529
+ };
530
+ }
531
+
532
+ // Windowed mode
533
+ let window = calculateResponsivePickerViewport({
534
+ itemCount: models.length,
535
+ selectedIndex: draftSelectedModel,
536
+ availableRows,
537
+ chromeRows: 1,
538
+ scrollOffset,
539
+ });
540
+ let showCurrentLine = activeModelIndex >= 0
541
+ && (activeModelIndex < window.start || activeModelIndex >= window.end);
542
+ if (showCurrentLine) {
543
+ window = calculateResponsivePickerViewport({
544
+ itemCount: models.length,
545
+ selectedIndex: draftSelectedModel,
546
+ availableRows,
547
+ chromeRows: 2,
548
+ scrollOffset,
549
+ });
550
+ showCurrentLine = activeModelIndex < window.start || activeModelIndex >= window.end;
551
+ }
552
+
553
+ return {
554
+ ...window,
555
+ mode: "windowed" as const,
556
+ showCurrentLine,
557
+ showRouteText: false,
558
+ showReasoningText: false,
559
+ showSourceMarker: false,
560
+ };
561
+ }, [models.length, draftSelectedModel, availableRows, hasSourceMarker, appLayoutBudget?.showPanelColumnHeaders, scrollOffset, activeModelIndex]);
562
+
563
+ useEffect(() => {
564
+ setScrollOffset(windowResult.start);
565
+ }, [windowResult.start]);
566
+
567
+ const visibleModels = useMemo(() => {
568
+ return models.slice(windowResult.start, windowResult.end);
569
+ }, [models, windowResult.start, windowResult.end]);
570
+
571
+ const activeModel = models[activeModelIndex];
572
+
573
+ const title = clampVisualText(
574
+ windowResult.mode === "windowed"
575
+ ? `Models · ${windowResult.selectedIndex + 1}/${models.length}`
576
+ : `Select model ${help}`,
577
+ innerWidth,
578
+ );
579
+
580
+
581
+ return (
582
+ <Box flexDirection="column" width={panelWidth}>
583
+ <Box
584
+ borderStyle="round"
585
+ borderColor={theme.prompt}
586
+ paddingX={1}
587
+ paddingY={0}
588
+ width={panelWidth}
589
+ flexDirection="column"
590
+ >
591
+ <Box width="100%" overflow="hidden">
592
+ <Text color={theme.accent} bold>{title}</Text>
593
+ </Box>
594
+ {windowResult.showRouteText && (
595
+ <Box width="100%" overflow="hidden">
596
+ <Text color={theme.textMuted}>
597
+ {clampVisualText(routeText, innerWidth)}
598
+ </Text>
599
+ </Box>
600
+ )}
601
+ {windowResult.showReasoningText && (
602
+ <Box width="100%" overflow="hidden">
603
+ <Text color={reasoningUnavailable ? theme.textDim : theme.textMuted}>
604
+ {clampVisualText(
605
+ models.length === 0
606
+ ? "Reasoning: current/default"
607
+ : reasoningUnavailable
608
+ ? (isAntigravity ? "Uses this model's native AGY configuration" : "Reasoning: unavailable · Intelligence: unavailable")
609
+ : `Reasoning: ${formatReasoningLabel(draftReasoning)} · Intelligence: ${formatReasoningLabel(draftReasoning)}`,
610
+ innerWidth,
611
+ )}
612
+ </Text>
613
+ </Box>
614
+ )}
615
+ {windowResult.showSourceMarker && sourceMarker && (
616
+ <Box width="100%" overflow="hidden">
617
+ <Text color={theme.textDim}>
618
+ {clampVisualText(sourceMarker, innerWidth)}
619
+ </Text>
620
+ </Box>
621
+ )}
622
+
623
+ {windowResult.showCurrentLine && activeModel && (
624
+ <Box height={1} overflow="hidden">
625
+ <Text color={theme.textMuted} wrap="truncate">Current: <Text color={theme.text} bold>{clampVisualText(getModelName(activeModel), Math.max(1, innerWidth - 9))}</Text></Text>
626
+ </Box>
627
+ )}
628
+
629
+ <Box
630
+ flexDirection="column"
631
+ marginTop={0}
632
+ width="100%"
633
+ height={models.length > 0 ? visibleModels.length : undefined}
634
+ overflow={models.length > 0 ? "hidden" : undefined}
635
+ >
636
+ {models.length === 0 ? (
637
+ <Text color={theme.textMuted}>
638
+ {isLoading
639
+ ? `Discovering models from ${activeProviderLabel === "OpenAI" ? "the Codex runtime" : activeProviderLabel}...`
640
+ : (emptyMessage ?? "No models available.")}
641
+ </Text>
642
+ ) : (
643
+ visibleModels.map((model, index) => {
644
+ const actualIndex = windowResult.start + index;
645
+ return (
646
+ <ModelPickerRow
647
+ key={model.id}
648
+ model={model}
649
+ width={innerWidth}
650
+ currentModel={currentModel}
651
+ currentGeminiSelection={currentGeminiSelection}
652
+ isHighlighted={actualIndex === draftSelectedModel}
653
+ />
654
+ );
655
+ })
656
+ )}
657
+ </Box>
658
+
659
+ {models.length > 0 && windowResult.mode !== "windowed" && (!isAntigravity || !reasoningUnavailable) && (
660
+ <IntelligenceSlider
661
+ levels={selectedReasoningLevels}
662
+ selected={draftReasoning}
663
+ width={innerWidth}
664
+ unavailable={reasoningUnavailable}
665
+ />
666
+ )}
667
+
668
+ </Box>
669
+ </Box>
670
+ );
671
+ }
672
+
673
+ // ─── Subcomponents ───────────────────────────────────────────────────────────
674
+
675
+ function ModelPickerRow({
676
+ model,
677
+ width,
678
+ currentModel,
679
+ currentGeminiSelection,
680
+ isHighlighted,
681
+ }: {
682
+ model: CodexModelCapability;
683
+ width: number;
684
+ currentModel: string;
685
+ currentGeminiSelection?: GeminiModelSelection;
686
+ isHighlighted: boolean;
687
+ }) {
688
+ const theme = useTheme();
689
+
690
+ let isCurrent = false;
691
+ if (currentGeminiSelection?.kind === "auto") {
692
+ isCurrent = (model.raw as GeminiModelSelection)?.kind === "auto" && (model.raw as any).family === currentGeminiSelection.family;
693
+ } else if (currentGeminiSelection?.kind === "manual") {
694
+ isCurrent = (model.raw as GeminiModelSelection)?.kind === "manual" && (model.raw as any).modelId === currentGeminiSelection.modelId;
695
+ } else {
696
+ isCurrent = model.model === currentModel || model.id === currentModel || getVariantModelIds(model).includes(currentModel);
697
+ }
698
+
699
+ const markerWidth = 2;
700
+ const checkWidth = 2;
701
+ const nameWidth = Math.max(8, width - markerWidth - checkWidth);
702
+ const name = clampVisualText(isHighlighted ? getModelName(model) : getCompactModelName(model), nameWidth);
703
+
704
+ return (
705
+ <Box width="100%" overflow="hidden">
706
+ <Box width={markerWidth} flexShrink={0}>
707
+ <Text color={isHighlighted ? theme.accent : theme.textDim}>{isHighlighted ? ">" : " "}</Text>
708
+ </Box>
709
+ <Box width={nameWidth} flexShrink={0} overflow="hidden">
710
+ <Text color={isHighlighted ? theme.text : theme.textMuted} bold={isHighlighted} wrap="truncate">
711
+ {name}
712
+ </Text>
713
+ </Box>
714
+ <Box width={checkWidth} flexShrink={0}>
715
+ <Text color={theme.textDim}>{isCurrent ? "✓" : " "}</Text>
716
+ </Box>
717
+ </Box>
718
+ );
719
+ }
720
+
721
+ function getCompactModelName(model: CodexModelCapability): string {
722
+ return model.label || model.model;
723
+ }
724
+
725
+ function IntelligenceSlider({
726
+ levels,
727
+ selected,
728
+ width,
729
+ unavailable,
730
+ }: {
731
+ levels: readonly ReasoningEffortCapability[];
732
+ selected: string;
733
+ width: number;
734
+ unavailable: boolean;
735
+ }) {
736
+ const theme = useTheme();
737
+ if (unavailable) {
738
+ return (
739
+ <Box marginTop={1} width="100%" overflow="hidden">
740
+ <Text color={theme.textDim}>Intelligence unavailable for this model</Text>
741
+ </Box>
742
+ );
743
+ }
744
+
745
+ const selectedIndex = Math.max(0, levels.findIndex((level) => level.id === selected));
746
+ const trackWidth = Math.max(5, Math.min(24, width - 26));
747
+ const thumbPosition = levels.length <= 1
748
+ ? 0
749
+ : Math.round((selectedIndex / (levels.length - 1)) * (trackWidth - 1));
750
+ const track = Array.from({ length: trackWidth }, (_, index) => index === thumbPosition ? "●" : "─").join("");
751
+ const low = formatReasoningLabel(levels[0]?.id ?? "low");
752
+ const high = formatReasoningLabel(levels[levels.length - 1]?.id ?? "high");
753
+ const value = formatReasoningLabel(levels[selectedIndex]?.id ?? selected);
754
+ const sliderText = `Intelligence ${low} ${track} ${high} ${value}`;
755
+
756
+ return (
757
+ <Box marginTop={1} width="100%" overflow="hidden">
758
+ <Text color={theme.textMuted} wrap="truncate">{clampVisualText(sliderText, width)}</Text>
759
+ </Box>
760
+ );
761
+ }