@golba98/codexa 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +320 -0
- package/bin/codexa.js +445 -0
- package/package.json +45 -0
- package/scripts/audit-codexa-capabilities.mjs +466 -0
- package/scripts/smoke-terminal-bench.mjs +35 -0
- package/src/app.tsx +4561 -0
- package/src/appRenderStability.test.ts +131 -0
- package/src/commands/handler.test.ts +643 -0
- package/src/commands/handler.ts +875 -0
- package/src/config/launchArgs.test.ts +158 -0
- package/src/config/launchArgs.ts +186 -0
- package/src/config/layeredConfig.test.ts +143 -0
- package/src/config/layeredConfig.ts +836 -0
- package/src/config/persistence.test.ts +110 -0
- package/src/config/persistence.ts +311 -0
- package/src/config/runtimeConfig.test.ts +218 -0
- package/src/config/runtimeConfig.ts +554 -0
- package/src/config/settings.test.ts +155 -0
- package/src/config/settings.ts +401 -0
- package/src/config/toml-serialize.ts +98 -0
- package/src/config/trustStore.test.ts +29 -0
- package/src/config/trustStore.ts +68 -0
- package/src/core/attachments.test.ts +155 -0
- package/src/core/attachments.ts +71 -0
- package/src/core/auth/codexAuth.test.ts +68 -0
- package/src/core/auth/codexAuth.ts +359 -0
- package/src/core/cleanupFastFail.test.ts +76 -0
- package/src/core/cleanupFastFail.ts +67 -0
- package/src/core/clipboard.ts +24 -0
- package/src/core/codex.ts +124 -0
- package/src/core/codexExecArgs.test.ts +195 -0
- package/src/core/codexExecArgs.ts +152 -0
- package/src/core/codexLaunch.test.ts +205 -0
- package/src/core/codexLaunch.ts +162 -0
- package/src/core/codexPrompt.test.ts +252 -0
- package/src/core/codexPrompt.ts +428 -0
- package/src/core/executables/claudeExecutable.ts +63 -0
- package/src/core/executables/codexExecutable.test.ts +212 -0
- package/src/core/executables/codexExecutable.ts +159 -0
- package/src/core/executables/executableResolver.test.ts +129 -0
- package/src/core/executables/executableResolver.ts +138 -0
- package/src/core/executables/geminiExecutable.test.ts +116 -0
- package/src/core/executables/geminiExecutable.ts +78 -0
- package/src/core/executables/pathSanityScan.test.ts +47 -0
- package/src/core/githubDiagnostics.test.ts +92 -0
- package/src/core/githubDiagnostics.ts +222 -0
- package/src/core/hollowResponseFormat.test.ts +58 -0
- package/src/core/hollowResponseFormat.ts +39 -0
- package/src/core/inputDebug.ts +51 -0
- package/src/core/launchContext.test.ts +157 -0
- package/src/core/launchContext.ts +266 -0
- package/src/core/models/codexCapabilities.test.ts +45 -0
- package/src/core/models/codexCapabilities.ts +95 -0
- package/src/core/models/codexModelCapabilities.test.ts +246 -0
- package/src/core/models/codexModelCapabilities.ts +571 -0
- package/src/core/models/modelSpecs.test.ts +283 -0
- package/src/core/models/modelSpecs.ts +300 -0
- package/src/core/perf/profiler.ts +125 -0
- package/src/core/perf/renderDebug.test.ts +230 -0
- package/src/core/perf/renderDebug.ts +373 -0
- package/src/core/planStorage.test.ts +143 -0
- package/src/core/planStorage.ts +141 -0
- package/src/core/process/CommandRunner.test.ts +105 -0
- package/src/core/process/CommandRunner.ts +269 -0
- package/src/core/process/processValidation.ts +101 -0
- package/src/core/projectInstructions.test.ts +50 -0
- package/src/core/projectInstructions.ts +54 -0
- package/src/core/providerLauncher/launcher.test.ts +238 -0
- package/src/core/providerLauncher/launcher.ts +203 -0
- package/src/core/providerLauncher/registry.test.ts +324 -0
- package/src/core/providerLauncher/registry.ts +253 -0
- package/src/core/providerLauncher/types.ts +84 -0
- package/src/core/providerLauncher/workspaceConfig.test.ts +638 -0
- package/src/core/providerLauncher/workspaceConfig.ts +407 -0
- package/src/core/providerRuntime/anthropic.test.ts +1120 -0
- package/src/core/providerRuntime/anthropic.ts +576 -0
- package/src/core/providerRuntime/capabilityProfile.test.ts +311 -0
- package/src/core/providerRuntime/capabilityProfile.ts +288 -0
- package/src/core/providerRuntime/claudeCodeDiscovery.ts +446 -0
- package/src/core/providerRuntime/contextMetadata.test.ts +468 -0
- package/src/core/providerRuntime/contextMetadata.ts +409 -0
- package/src/core/providerRuntime/gemini.test.ts +437 -0
- package/src/core/providerRuntime/gemini.ts +784 -0
- package/src/core/providerRuntime/lmstudio.test.ts +168 -0
- package/src/core/providerRuntime/lmstudio.ts +118 -0
- package/src/core/providerRuntime/local.test.ts +787 -0
- package/src/core/providerRuntime/local.ts +754 -0
- package/src/core/providerRuntime/models.ts +150 -0
- package/src/core/providerRuntime/reasoning.ts +17 -0
- package/src/core/providerRuntime/registry.test.ts +233 -0
- package/src/core/providerRuntime/registry.ts +203 -0
- package/src/core/providerRuntime/types.ts +103 -0
- package/src/core/providers/codexJsonStream.test.ts +148 -0
- package/src/core/providers/codexJsonStream.ts +305 -0
- package/src/core/providers/codexSubprocess.test.ts +68 -0
- package/src/core/providers/codexSubprocess.ts +372 -0
- package/src/core/providers/codexTranscript.test.ts +284 -0
- package/src/core/providers/codexTranscript.ts +695 -0
- package/src/core/providers/openaiNative.ts +13 -0
- package/src/core/providers/registry.ts +21 -0
- package/src/core/providers/types.ts +59 -0
- package/src/core/terminal/terminalCapabilities.test.ts +93 -0
- package/src/core/terminal/terminalCapabilities.ts +100 -0
- package/src/core/terminal/terminalControl.test.ts +75 -0
- package/src/core/terminal/terminalControl.ts +147 -0
- package/src/core/terminal/terminalSanitize.test.ts +22 -0
- package/src/core/terminal/terminalSanitize.ts +147 -0
- package/src/core/terminal/terminalSelection.test.ts +42 -0
- package/src/core/terminal/terminalSelection.ts +66 -0
- package/src/core/terminal/terminalTitle.test.ts +328 -0
- package/src/core/terminal/terminalTitle.ts +483 -0
- package/src/core/workspaceActivity.test.ts +163 -0
- package/src/core/workspaceActivity.ts +380 -0
- package/src/core/workspaceGuard.test.ts +151 -0
- package/src/core/workspaceGuard.ts +288 -0
- package/src/core/workspaceRoot.test.ts +23 -0
- package/src/core/workspaceRoot.ts +47 -0
- package/src/exec.test.ts +13 -0
- package/src/exec.ts +72 -0
- package/src/headless/execArgs.test.ts +147 -0
- package/src/headless/execArgs.ts +294 -0
- package/src/headless/execRunner.test.ts +434 -0
- package/src/headless/execRunner.ts +304 -0
- package/src/index.test.tsx +618 -0
- package/src/index.tsx +296 -0
- package/src/session/appSession.test.ts +897 -0
- package/src/session/appSession.ts +761 -0
- package/src/session/chatLifecycle.test.ts +64 -0
- package/src/session/chatLifecycle.ts +951 -0
- package/src/session/liveRenderScheduler.test.ts +201 -0
- package/src/session/liveRenderScheduler.ts +214 -0
- package/src/session/planFlow.test.ts +103 -0
- package/src/session/planFlow.ts +149 -0
- package/src/session/planTranscript.test.ts +65 -0
- package/src/session/planTranscript.ts +15 -0
- package/src/session/promptRunSchedule.test.ts +36 -0
- package/src/session/promptRunSchedule.ts +26 -0
- package/src/session/types.ts +228 -0
- package/src/test/runtimeTestUtils.ts +14 -0
- package/src/types/react-dom.d.ts +3 -0
- package/src/ui/ActionRequiredBlock.tsx +38 -0
- package/src/ui/ActivityBars.tsx +68 -0
- package/src/ui/ActivityIndicator.test.tsx +58 -0
- package/src/ui/ActivityIndicator.tsx +58 -0
- package/src/ui/AgentBlock.test.ts +6 -0
- package/src/ui/AgentBlock.tsx +130 -0
- package/src/ui/AnimatedStatusText.test.ts +16 -0
- package/src/ui/AnimatedStatusText.tsx +69 -0
- package/src/ui/AppShell.test.tsx +1739 -0
- package/src/ui/AppShell.tsx +698 -0
- package/src/ui/AttachmentImportPanel.test.tsx +204 -0
- package/src/ui/AttachmentImportPanel.tsx +98 -0
- package/src/ui/AuthPanel.tsx +113 -0
- package/src/ui/BackendPicker.tsx +28 -0
- package/src/ui/BottomComposer.test.ts +674 -0
- package/src/ui/BottomComposer.tsx +1028 -0
- package/src/ui/CodexLogo.tsx +55 -0
- package/src/ui/DashCard.tsx +82 -0
- package/src/ui/Markdown.test.ts +157 -0
- package/src/ui/Markdown.tsx +310 -0
- package/src/ui/ModePicker.tsx +27 -0
- package/src/ui/ModelPicker.tsx +31 -0
- package/src/ui/ModelPickerProviderScope.test.tsx +411 -0
- package/src/ui/ModelPickerScreen.test.tsx +99 -0
- package/src/ui/ModelPickerScreen.tsx +416 -0
- package/src/ui/ModelPickerState.test.tsx +151 -0
- package/src/ui/ModelReasoningPicker.test.tsx +447 -0
- package/src/ui/ModelReasoningPicker.tsx +458 -0
- package/src/ui/Panel.tsx +51 -0
- package/src/ui/PermissionsPanel.tsx +78 -0
- package/src/ui/PlanActionPicker.tsx +119 -0
- package/src/ui/PlanReviewPanel.test.tsx +267 -0
- package/src/ui/PlanReviewPanel.tsx +212 -0
- package/src/ui/PromptCardBorder.test.tsx +161 -0
- package/src/ui/ProviderPicker.test.tsx +289 -0
- package/src/ui/ProviderPicker.tsx +321 -0
- package/src/ui/ProviderShortcut.test.tsx +143 -0
- package/src/ui/ReasoningPicker.tsx +46 -0
- package/src/ui/RunFooter.tsx +65 -0
- package/src/ui/SelectionPanel.tsx +67 -0
- package/src/ui/SettingsPanel.test.tsx +233 -0
- package/src/ui/SettingsPanel.tsx +156 -0
- package/src/ui/Spinner.tsx +25 -0
- package/src/ui/StaticIntroItem.tsx +54 -0
- package/src/ui/StaticTranscriptItem.tsx +56 -0
- package/src/ui/TextEntryPanel.tsx +139 -0
- package/src/ui/ThemePicker.tsx +31 -0
- package/src/ui/ThinkingBlock.tsx +100 -0
- package/src/ui/Timeline.test.ts +2067 -0
- package/src/ui/Timeline.tsx +1472 -0
- package/src/ui/TimelineNavigation.test.tsx +201 -0
- package/src/ui/TopHeader.test.tsx +239 -0
- package/src/ui/TopHeader.tsx +257 -0
- package/src/ui/TurnGroup.test.tsx +365 -0
- package/src/ui/TurnGroup.tsx +657 -0
- package/src/ui/busyStatusAnimation.test.ts +30 -0
- package/src/ui/busyStatusAnimation.ts +11 -0
- package/src/ui/commandNormalize.test.ts +142 -0
- package/src/ui/commandNormalize.ts +66 -0
- package/src/ui/diffRenderer.test.ts +102 -0
- package/src/ui/diffRenderer.ts +116 -0
- package/src/ui/focus.ts +61 -0
- package/src/ui/focusFlow.test.tsx +1098 -0
- package/src/ui/inputBuffer.test.ts +151 -0
- package/src/ui/inputBuffer.ts +203 -0
- package/src/ui/layout.test.ts +145 -0
- package/src/ui/layout.ts +287 -0
- package/src/ui/modeDisplay.test.ts +42 -0
- package/src/ui/modeDisplay.ts +52 -0
- package/src/ui/outputPipeline.ts +64 -0
- package/src/ui/progressEntries.ts +156 -0
- package/src/ui/runActivityView.test.ts +89 -0
- package/src/ui/runActivityView.ts +37 -0
- package/src/ui/runLifecycleView.test.tsx +237 -0
- package/src/ui/slashCommands.ts +41 -0
- package/src/ui/statusRenderIsolation.test.tsx +654 -0
- package/src/ui/terminalAnswerFormat.test.ts +19 -0
- package/src/ui/terminalAnswerFormat.ts +128 -0
- package/src/ui/textLayout.test.ts +18 -0
- package/src/ui/textLayout.ts +338 -0
- package/src/ui/theme.tsx +395 -0
- package/src/ui/themeFlow.test.ts +53 -0
- package/src/ui/themeFlow.ts +41 -0
- package/src/ui/timelineMeasure.ts +3088 -0
- package/src/ui/timelineMeasureCache.test.ts +986 -0
- package/src/ui/useThrottledValue.ts +31 -0
|
@@ -0,0 +1,416 @@
|
|
|
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/inputDebug.js";
|
|
10
|
+
import { FOCUS_IDS } from "./focus.js";
|
|
11
|
+
import { clampVisualText, getShellWidth, type Layout } from "./layout.js";
|
|
12
|
+
import { useTheme } from "./theme.js";
|
|
13
|
+
import type { GeminiModelSelection } from "../core/providerRuntime/types.js";
|
|
14
|
+
|
|
15
|
+
// ─── Types & helpers ─────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
type ModelPickerCloseReason = "escape" | "empty-selection";
|
|
18
|
+
|
|
19
|
+
interface ModelPickerScreenProps {
|
|
20
|
+
layout: Layout;
|
|
21
|
+
models: readonly CodexModelCapability[];
|
|
22
|
+
currentModel: string;
|
|
23
|
+
currentReasoning: string;
|
|
24
|
+
currentGeminiSelection?: GeminiModelSelection;
|
|
25
|
+
activeProviderLabel?: string;
|
|
26
|
+
isLoading?: boolean;
|
|
27
|
+
emptyMessage?: string;
|
|
28
|
+
routeTextOverride?: string;
|
|
29
|
+
onSelect: (model: string, reasoning: string, geminiSelection?: GeminiModelSelection) => void;
|
|
30
|
+
onCancel: (reason?: ModelPickerCloseReason) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getInitialCursor(models: readonly CodexModelCapability[], currentModel: string, currentGeminiSelection?: GeminiModelSelection): number {
|
|
34
|
+
if (currentGeminiSelection?.kind === "auto") {
|
|
35
|
+
const familyId = currentGeminiSelection.family === "gemini-3" ? "auto-gemini-3" : "auto-gemini-2.5";
|
|
36
|
+
const index = models.findIndex((m) => m.id === familyId);
|
|
37
|
+
if (index >= 0) return index;
|
|
38
|
+
}
|
|
39
|
+
const index = models.findIndex((model) => model.model === currentModel || model.id === currentModel);
|
|
40
|
+
return Math.max(0, index);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getModelName(model: CodexModelCapability): string {
|
|
44
|
+
return model.label === model.model ? model.model : `${model.label} (${model.model})`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getReasoningLevels(model: CodexModelCapability | undefined): readonly ReasoningEffortCapability[] {
|
|
48
|
+
return model?.supportedReasoningLevels ?? [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getModelSourceMarker(models: readonly CodexModelCapability[], activeProviderLabel: string): string | null {
|
|
52
|
+
if (activeProviderLabel !== "Claude" || models.length === 0) return null;
|
|
53
|
+
const source = (models[0]?.raw as { source?: string } | null | undefined)?.source;
|
|
54
|
+
if (source === "claude-code" || source === "discovered") return "Discovered from Claude Code";
|
|
55
|
+
if (source === "settings" || source === "config") return "From Claude settings";
|
|
56
|
+
return "Fallback defaults";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeDraftReasoning(
|
|
60
|
+
model: CodexModelCapability | undefined,
|
|
61
|
+
reasoning: string,
|
|
62
|
+
): string {
|
|
63
|
+
if (!model) return reasoning;
|
|
64
|
+
return normalizeReasoningForModelCapabilities(
|
|
65
|
+
model.model,
|
|
66
|
+
reasoning,
|
|
67
|
+
{
|
|
68
|
+
status: "ready",
|
|
69
|
+
source: model.source,
|
|
70
|
+
models: [model],
|
|
71
|
+
discoveredAt: Date.now(),
|
|
72
|
+
executable: null,
|
|
73
|
+
error: null,
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getReasoningIndex(levels: readonly ReasoningEffortCapability[], reasoning: string): number {
|
|
79
|
+
return Math.max(0, levels.findIndex((level) => level.id === reasoning));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function describeInputKey(
|
|
83
|
+
input: string,
|
|
84
|
+
key: {
|
|
85
|
+
escape?: boolean;
|
|
86
|
+
return?: boolean;
|
|
87
|
+
upArrow?: boolean;
|
|
88
|
+
downArrow?: boolean;
|
|
89
|
+
leftArrow?: boolean;
|
|
90
|
+
rightArrow?: boolean;
|
|
91
|
+
ctrl?: boolean;
|
|
92
|
+
meta?: boolean;
|
|
93
|
+
},
|
|
94
|
+
) {
|
|
95
|
+
return {
|
|
96
|
+
input,
|
|
97
|
+
escape: Boolean(key.escape),
|
|
98
|
+
return: Boolean(key.return),
|
|
99
|
+
upArrow: Boolean(key.upArrow),
|
|
100
|
+
downArrow: Boolean(key.downArrow),
|
|
101
|
+
leftArrow: Boolean(key.leftArrow),
|
|
102
|
+
rightArrow: Boolean(key.rightArrow),
|
|
103
|
+
ctrl: Boolean(key.ctrl),
|
|
104
|
+
meta: Boolean(key.meta),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── Component ────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
export function ModelPickerScreen({
|
|
111
|
+
layout,
|
|
112
|
+
models: baseModels,
|
|
113
|
+
currentModel,
|
|
114
|
+
currentReasoning,
|
|
115
|
+
currentGeminiSelection,
|
|
116
|
+
activeProviderLabel = "OpenAI",
|
|
117
|
+
isLoading = false,
|
|
118
|
+
emptyMessage,
|
|
119
|
+
routeTextOverride,
|
|
120
|
+
onSelect,
|
|
121
|
+
onCancel,
|
|
122
|
+
}: ModelPickerScreenProps) {
|
|
123
|
+
const theme = useTheme();
|
|
124
|
+
const isGoogle = activeProviderLabel === "Google";
|
|
125
|
+
|
|
126
|
+
const models = useMemo(() => {
|
|
127
|
+
if (!isGoogle) return baseModels;
|
|
128
|
+
|
|
129
|
+
const autoModels: CodexModelCapability[] = [
|
|
130
|
+
{
|
|
131
|
+
id: "auto-gemini-3",
|
|
132
|
+
model: "gemini-3-flash-preview",
|
|
133
|
+
label: "Auto (Gemini 3)",
|
|
134
|
+
description: "Best available verified Gemini 3 model.",
|
|
135
|
+
available: true,
|
|
136
|
+
hidden: false,
|
|
137
|
+
isDefault: false,
|
|
138
|
+
defaultReasoningLevel: "medium",
|
|
139
|
+
supportedReasoningLevels: null,
|
|
140
|
+
reasoningLevelCount: null,
|
|
141
|
+
source: "fallback",
|
|
142
|
+
raw: { kind: "auto", family: "gemini-3" },
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: "auto-gemini-2.5",
|
|
146
|
+
model: "gemini-2.5-pro",
|
|
147
|
+
label: "Auto (Gemini 2.5)",
|
|
148
|
+
description: "Best available Gemini 2.5 model.",
|
|
149
|
+
available: true,
|
|
150
|
+
hidden: false,
|
|
151
|
+
isDefault: false,
|
|
152
|
+
defaultReasoningLevel: "high",
|
|
153
|
+
supportedReasoningLevels: null,
|
|
154
|
+
reasoningLevelCount: null,
|
|
155
|
+
source: "fallback",
|
|
156
|
+
raw: { kind: "auto", family: "gemini-2.5" },
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
const manualModels = baseModels.map((m) => ({
|
|
161
|
+
...m,
|
|
162
|
+
label: `Manual: ${m.label}`,
|
|
163
|
+
raw: { kind: "manual", modelId: m.model },
|
|
164
|
+
}));
|
|
165
|
+
|
|
166
|
+
return [...autoModels, ...manualModels];
|
|
167
|
+
}, [baseModels, isGoogle]);
|
|
168
|
+
|
|
169
|
+
const { isFocused } = useFocus({ id: FOCUS_IDS.modelPicker, autoFocus: true });
|
|
170
|
+
const [draftSelectedModel, setDraftSelectedModel] = useState(() => getInitialCursor(models, currentModel, currentGeminiSelection));
|
|
171
|
+
const [draftReasoning, setDraftReasoning] = useState(() =>
|
|
172
|
+
normalizeDraftReasoning(models[getInitialCursor(models, currentModel, currentGeminiSelection)], currentReasoning)
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const selectedModel = models[draftSelectedModel];
|
|
176
|
+
const selectedReasoningLevels = getReasoningLevels(selectedModel);
|
|
177
|
+
const reasoningUnavailable = selectedReasoningLevels.length === 0;
|
|
178
|
+
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
traceInputDebug("model_picker_panel_mounted", {
|
|
181
|
+
focusTarget: FOCUS_IDS.modelPicker,
|
|
182
|
+
modelCount: models.length,
|
|
183
|
+
isLoading,
|
|
184
|
+
});
|
|
185
|
+
return () => {
|
|
186
|
+
traceInputDebug("model_picker_panel_unmounted", {
|
|
187
|
+
focusTarget: FOCUS_IDS.modelPicker,
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
}, []);
|
|
191
|
+
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
if (models.length === 0) {
|
|
194
|
+
setDraftSelectedModel(0);
|
|
195
|
+
setDraftReasoning(currentReasoning);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
setDraftSelectedModel((current) => {
|
|
200
|
+
const nextCursor = Math.min(Math.max(0, current), models.length - 1);
|
|
201
|
+
const nextModel = models[nextCursor];
|
|
202
|
+
setDraftReasoning((reasoning) => normalizeDraftReasoning(nextModel, reasoning));
|
|
203
|
+
return nextCursor;
|
|
204
|
+
});
|
|
205
|
+
}, [currentReasoning, models]);
|
|
206
|
+
|
|
207
|
+
const moveModel = (direction: -1 | 1) => {
|
|
208
|
+
setDraftSelectedModel((current) => {
|
|
209
|
+
const next = Math.max(0, Math.min(models.length - 1, current + direction));
|
|
210
|
+
const nextModel = models[next];
|
|
211
|
+
setDraftReasoning((reasoning) => normalizeDraftReasoning(nextModel, reasoning));
|
|
212
|
+
return next;
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const moveReasoning = (direction: -1 | 1) => {
|
|
217
|
+
const levels = getReasoningLevels(models[draftSelectedModel]);
|
|
218
|
+
if (levels.length <= 1) return;
|
|
219
|
+
|
|
220
|
+
setDraftReasoning((current) => {
|
|
221
|
+
const currentIndex = getReasoningIndex(levels, current);
|
|
222
|
+
const nextIndex = Math.max(0, Math.min(levels.length - 1, currentIndex + direction));
|
|
223
|
+
return levels[nextIndex]?.id ?? current;
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
useInput(
|
|
228
|
+
(input, key) => {
|
|
229
|
+
traceInputDebug("model_picker_panel_input", {
|
|
230
|
+
handler: "ModelPickerScreen.useInput",
|
|
231
|
+
key: describeInputKey(input, key),
|
|
232
|
+
isFocused,
|
|
233
|
+
isLoading,
|
|
234
|
+
modelCount: models.length,
|
|
235
|
+
cursor: draftSelectedModel,
|
|
236
|
+
draftReasoning,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (key.ctrl && (input === "c" || input === "q")) {
|
|
240
|
+
onCancel("escape");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (key.escape) {
|
|
245
|
+
onCancel("escape");
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (key.return) {
|
|
250
|
+
const model = models[draftSelectedModel];
|
|
251
|
+
if (!model) {
|
|
252
|
+
onCancel("empty-selection");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const geminiSelection = isGoogle ? (model.raw as GeminiModelSelection) : undefined;
|
|
256
|
+
onSelect(model.model, normalizeDraftReasoning(model, draftReasoning), geminiSelection);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (key.upArrow || input === "k") {
|
|
261
|
+
moveModel(-1);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (key.downArrow || input === "j") {
|
|
266
|
+
moveModel(1);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (key.leftArrow || input === "h") {
|
|
271
|
+
moveReasoning(-1);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (key.rightArrow || input === "l") {
|
|
276
|
+
moveReasoning(1);
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
{ isActive: isFocused },
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const shellWidth = getShellWidth(layout.cols);
|
|
283
|
+
const panelWidth = Math.max(38, Math.min(shellWidth - 2, layout.mode === "full" ? 74 : 64));
|
|
284
|
+
const innerWidth = Math.max(20, panelWidth - 4);
|
|
285
|
+
const help = layout.mode === "micro"
|
|
286
|
+
? "↑↓ · ←→ · Enter · Esc"
|
|
287
|
+
: "↑↓ model · ←→ reasoning · Enter select · Esc cancel";
|
|
288
|
+
const title = clampVisualText(`Select model ${help}`, innerWidth);
|
|
289
|
+
const aOrAn = /^[aeiou]/i.test(activeProviderLabel) ? "an" : "a";
|
|
290
|
+
const routeText = routeTextOverride ?? `Choose ${aOrAn} ${activeProviderLabel} model to use inside Codexa.`;
|
|
291
|
+
const reasoningText = reasoningUnavailable
|
|
292
|
+
? (models.length === 0 ? "Reasoning: current/default" : "Reasoning: unavailable")
|
|
293
|
+
: `Reasoning: ${formatReasoningLabel(draftReasoning)}`;
|
|
294
|
+
const sourceMarker = getModelSourceMarker(models, activeProviderLabel);
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<Box flexDirection="column" width={panelWidth}>
|
|
298
|
+
<Box
|
|
299
|
+
borderStyle="round"
|
|
300
|
+
borderColor={theme.PROMPT}
|
|
301
|
+
paddingX={1}
|
|
302
|
+
paddingY={0}
|
|
303
|
+
width={panelWidth}
|
|
304
|
+
flexDirection="column"
|
|
305
|
+
>
|
|
306
|
+
<Box width="100%" overflow="hidden">
|
|
307
|
+
<Text color={theme.ACCENT} bold>{title}</Text>
|
|
308
|
+
</Box>
|
|
309
|
+
<Box width="100%" overflow="hidden">
|
|
310
|
+
<Text color={theme.MUTED}>
|
|
311
|
+
{clampVisualText(routeText, innerWidth)}
|
|
312
|
+
</Text>
|
|
313
|
+
</Box>
|
|
314
|
+
<Box width="100%" overflow="hidden">
|
|
315
|
+
<Text color={reasoningUnavailable ? theme.DIM : theme.MUTED}>
|
|
316
|
+
{clampVisualText(reasoningText, innerWidth)}
|
|
317
|
+
</Text>
|
|
318
|
+
</Box>
|
|
319
|
+
{sourceMarker && (
|
|
320
|
+
<Box width="100%" overflow="hidden">
|
|
321
|
+
<Text color={theme.DIM}>
|
|
322
|
+
{clampVisualText(sourceMarker, innerWidth)}
|
|
323
|
+
</Text>
|
|
324
|
+
</Box>
|
|
325
|
+
)}
|
|
326
|
+
|
|
327
|
+
<Box flexDirection="column" marginTop={0} width="100%">
|
|
328
|
+
{models.length === 0 ? (
|
|
329
|
+
<Text color={theme.MUTED}>
|
|
330
|
+
{isLoading ? "Discovering models from the Codex runtime..." : (emptyMessage ?? "No models available.")}
|
|
331
|
+
</Text>
|
|
332
|
+
) : (
|
|
333
|
+
models.map((model, index) => (
|
|
334
|
+
<ModelPickerRow
|
|
335
|
+
key={model.id}
|
|
336
|
+
model={model}
|
|
337
|
+
width={innerWidth}
|
|
338
|
+
currentModel={currentModel}
|
|
339
|
+
currentGeminiSelection={currentGeminiSelection}
|
|
340
|
+
isHighlighted={index === draftSelectedModel}
|
|
341
|
+
selectedReasoning={index === draftSelectedModel ? draftReasoning : normalizeDraftReasoning(model, currentReasoning)}
|
|
342
|
+
/>
|
|
343
|
+
))
|
|
344
|
+
)}
|
|
345
|
+
</Box>
|
|
346
|
+
</Box>
|
|
347
|
+
</Box>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ─── Subcomponents ───────────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
function ModelPickerRow({
|
|
354
|
+
model,
|
|
355
|
+
width,
|
|
356
|
+
currentModel,
|
|
357
|
+
currentGeminiSelection,
|
|
358
|
+
isHighlighted,
|
|
359
|
+
selectedReasoning,
|
|
360
|
+
}: {
|
|
361
|
+
model: CodexModelCapability;
|
|
362
|
+
width: number;
|
|
363
|
+
currentModel: string;
|
|
364
|
+
currentGeminiSelection?: GeminiModelSelection;
|
|
365
|
+
isHighlighted: boolean;
|
|
366
|
+
selectedReasoning: string;
|
|
367
|
+
}) {
|
|
368
|
+
const theme = useTheme();
|
|
369
|
+
|
|
370
|
+
let isCurrent = false;
|
|
371
|
+
if (currentGeminiSelection?.kind === "auto") {
|
|
372
|
+
isCurrent = (model.raw as GeminiModelSelection)?.kind === "auto" && (model.raw as any).family === currentGeminiSelection.family;
|
|
373
|
+
} else if (currentGeminiSelection?.kind === "manual") {
|
|
374
|
+
isCurrent = (model.raw as GeminiModelSelection)?.kind === "manual" && (model.raw as any).modelId === currentGeminiSelection.modelId;
|
|
375
|
+
} else {
|
|
376
|
+
isCurrent = model.model === currentModel || model.id === currentModel;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const levels = getReasoningLevels(model);
|
|
380
|
+
const markerWidth = 2;
|
|
381
|
+
const checkWidth = 2;
|
|
382
|
+
const reasoningPill = levels.length > 0 ? `[${formatReasoningLabel(selectedReasoning)}]` : "";
|
|
383
|
+
const pillWidth = isHighlighted && width >= 48 ? Math.min(reasoningPill.length, 14) : 0;
|
|
384
|
+
const gapWidth = pillWidth > 0 ? 2 : 0;
|
|
385
|
+
const nameWidth = Math.max(8, width - markerWidth - checkWidth - pillWidth - gapWidth);
|
|
386
|
+
const name = clampVisualText(getModelName(model), nameWidth);
|
|
387
|
+
const pillText = pillWidth > 0 ? clampVisualText(reasoningPill, pillWidth) : "";
|
|
388
|
+
|
|
389
|
+
return (
|
|
390
|
+
<Box width="100%" overflow="hidden">
|
|
391
|
+
<Box width={markerWidth} flexShrink={0}>
|
|
392
|
+
<Text color={isHighlighted ? theme.ACCENT : theme.DIM}>{isHighlighted ? ">" : " "}</Text>
|
|
393
|
+
</Box>
|
|
394
|
+
<Box width={nameWidth} flexShrink={0} overflow="hidden">
|
|
395
|
+
<Text color={isHighlighted ? theme.TEXT : theme.MUTED} bold={isHighlighted}>
|
|
396
|
+
{name}
|
|
397
|
+
</Text>
|
|
398
|
+
</Box>
|
|
399
|
+
<Box width={checkWidth} flexShrink={0}>
|
|
400
|
+
<Text color={theme.DIM}>{isCurrent ? "✓" : " "}</Text>
|
|
401
|
+
</Box>
|
|
402
|
+
{pillWidth > 0 && (
|
|
403
|
+
<>
|
|
404
|
+
<Box width={gapWidth} flexShrink={0}>
|
|
405
|
+
<Text> </Text>
|
|
406
|
+
</Box>
|
|
407
|
+
<Box width={pillWidth} flexShrink={0} overflow="hidden">
|
|
408
|
+
<Text color={theme.ACCENT} bold>
|
|
409
|
+
{pillText}
|
|
410
|
+
</Text>
|
|
411
|
+
</Box>
|
|
412
|
+
</>
|
|
413
|
+
)}
|
|
414
|
+
</Box>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { PassThrough } from "node:stream";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { render } from "ink";
|
|
6
|
+
import { ThemeProvider } from "./theme.js";
|
|
7
|
+
import { ModelPickerScreen } from "./ModelPickerScreen.js";
|
|
8
|
+
import { createLayoutSnapshot } from "./layout.js";
|
|
9
|
+
|
|
10
|
+
class TestInput extends PassThrough {
|
|
11
|
+
readonly isTTY = true;
|
|
12
|
+
setRawMode(): this { return this; }
|
|
13
|
+
override resume(): this { return this; }
|
|
14
|
+
override pause(): this { return this; }
|
|
15
|
+
ref(): this { return this; }
|
|
16
|
+
unref(): this { return this; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class TestOutput extends PassThrough {
|
|
20
|
+
readonly isTTY = true;
|
|
21
|
+
columns = 120;
|
|
22
|
+
rows = 40;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function stripAnsi(value: string): string {
|
|
26
|
+
return value.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sleep(ms = 50): Promise<void> {
|
|
30
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("model picker displays grammar-correct selection message for OpenAI", async () => {
|
|
34
|
+
const stdin = new TestInput();
|
|
35
|
+
const stdout = new TestOutput();
|
|
36
|
+
let output = "";
|
|
37
|
+
stdout.on("data", (chunk) => { output += chunk.toString(); });
|
|
38
|
+
|
|
39
|
+
const { cleanup } = render(
|
|
40
|
+
<ThemeProvider theme="purple">
|
|
41
|
+
<ModelPickerScreen
|
|
42
|
+
layout={createLayoutSnapshot(120, 40)}
|
|
43
|
+
models={[]}
|
|
44
|
+
currentModel="gpt-4o"
|
|
45
|
+
currentReasoning="medium"
|
|
46
|
+
activeProviderLabel="OpenAI"
|
|
47
|
+
onSelect={() => {}}
|
|
48
|
+
onCancel={() => {}}
|
|
49
|
+
/>
|
|
50
|
+
</ThemeProvider>,
|
|
51
|
+
{ stdin: stdin as any, stdout: stdout as any, debug: true }
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
await sleep(100);
|
|
56
|
+
const stripped = stripAnsi(output);
|
|
57
|
+
assert.match(stripped, /Choose an OpenAI model to use inside Codexa/);
|
|
58
|
+
} finally {
|
|
59
|
+
cleanup();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("model picker displays grammar-correct selection message for Google", async () => {
|
|
64
|
+
const stdin = new TestInput();
|
|
65
|
+
const stdout = new TestOutput();
|
|
66
|
+
let output = "";
|
|
67
|
+
stdout.on("data", (chunk) => { output += chunk.toString(); });
|
|
68
|
+
|
|
69
|
+
const { cleanup } = render(
|
|
70
|
+
<ThemeProvider theme="purple">
|
|
71
|
+
<ModelPickerScreen
|
|
72
|
+
layout={createLayoutSnapshot(120, 40)}
|
|
73
|
+
models={[]}
|
|
74
|
+
currentModel="gpt-4o"
|
|
75
|
+
currentReasoning="medium"
|
|
76
|
+
activeProviderLabel="Google"
|
|
77
|
+
onSelect={() => {}}
|
|
78
|
+
onCancel={() => {}}
|
|
79
|
+
/>
|
|
80
|
+
</ThemeProvider>,
|
|
81
|
+
{ stdin: stdin as any, stdout: stdout as any, debug: true }
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
await sleep(100);
|
|
86
|
+
const stripped = stripAnsi(output);
|
|
87
|
+
assert.match(stripped, /Choose a Google model to use inside Codexa/);
|
|
88
|
+
} finally {
|
|
89
|
+
cleanup();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("model picker displays reasoning: current/default when models are empty", async () => {
|
|
94
|
+
const stdin = new TestInput();
|
|
95
|
+
const stdout = new TestOutput();
|
|
96
|
+
let output = "";
|
|
97
|
+
stdout.on("data", (chunk) => { output += chunk.toString(); });
|
|
98
|
+
|
|
99
|
+
const { cleanup } = render(
|
|
100
|
+
<ThemeProvider theme="purple">
|
|
101
|
+
<ModelPickerScreen
|
|
102
|
+
layout={createLayoutSnapshot(120, 40)}
|
|
103
|
+
models={[]}
|
|
104
|
+
currentModel="gpt-4o"
|
|
105
|
+
currentReasoning="medium"
|
|
106
|
+
onSelect={() => {}}
|
|
107
|
+
onCancel={() => {}}
|
|
108
|
+
/>
|
|
109
|
+
</ThemeProvider>,
|
|
110
|
+
{ stdin: stdin as any, stdout: stdout as any, debug: true }
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
await sleep(100);
|
|
115
|
+
const stripped = stripAnsi(output);
|
|
116
|
+
assert.match(stripped, /Reasoning: current\/default/);
|
|
117
|
+
assert.match(stripped, /No models available/);
|
|
118
|
+
} finally {
|
|
119
|
+
cleanup();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("model picker displays emptyMessage when provided", async () => {
|
|
124
|
+
const stdin = new TestInput();
|
|
125
|
+
const stdout = new TestOutput();
|
|
126
|
+
let output = "";
|
|
127
|
+
stdout.on("data", (chunk) => { output += chunk.toString(); });
|
|
128
|
+
|
|
129
|
+
const { cleanup } = render(
|
|
130
|
+
<ThemeProvider theme="purple">
|
|
131
|
+
<ModelPickerScreen
|
|
132
|
+
layout={createLayoutSnapshot(120, 40)}
|
|
133
|
+
models={[]}
|
|
134
|
+
currentModel="gpt-4o"
|
|
135
|
+
currentReasoning="medium"
|
|
136
|
+
emptyMessage="Custom empty message"
|
|
137
|
+
onSelect={() => {}}
|
|
138
|
+
onCancel={() => {}}
|
|
139
|
+
/>
|
|
140
|
+
</ThemeProvider>,
|
|
141
|
+
{ stdin: stdin as any, stdout: stdout as any, debug: true }
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await sleep(100);
|
|
146
|
+
const stripped = stripAnsi(output);
|
|
147
|
+
assert.match(stripped, /Custom empty message/);
|
|
148
|
+
} finally {
|
|
149
|
+
cleanup();
|
|
150
|
+
}
|
|
151
|
+
});
|