@golba98/codexa 1.0.2 → 1.0.4

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 (206) hide show
  1. package/README.md +396 -100
  2. package/bin/codexa.js +62 -144
  3. package/package.json +14 -8
  4. package/src/app.tsx +596 -306
  5. package/src/commands/handler.ts +6 -6
  6. package/src/config/buildInfo.ts +2 -2
  7. package/src/config/layeredConfig.ts +1 -1
  8. package/src/config/persistence.ts +10 -0
  9. package/src/config/runtimeConfig.ts +1 -1
  10. package/src/config/settings.ts +8 -16
  11. package/src/config/trustStore.ts +1 -1
  12. package/src/config/updateCheckCache.ts +19 -1
  13. package/src/core/README.md +52 -0
  14. package/src/core/agent/loop.ts +282 -0
  15. package/src/core/agent/protocol.ts +211 -0
  16. package/src/core/agent/tools.ts +414 -0
  17. package/src/core/{codexExecArgs.ts → codex/codexExecArgs.ts} +2 -2
  18. package/src/core/{codexLaunch.ts → codex/codexLaunch.ts} +3 -3
  19. package/src/core/{codexPrompt.ts → codex/codexPrompt.ts} +3 -3
  20. package/src/core/debug/modelStateDebug.ts +34 -0
  21. package/src/core/executables/antigravityExecutable.ts +48 -0
  22. package/src/core/executables/codexExecutable.ts +1 -0
  23. package/src/core/executables/executableResolver.ts +65 -43
  24. package/src/core/perf/renderDebug.ts +10 -6
  25. package/src/core/process/processValidation.ts +9 -5
  26. package/src/core/providerLauncher/launcher.ts +59 -42
  27. package/src/core/providerLauncher/registry.ts +30 -14
  28. package/src/core/providerLauncher/types.ts +11 -9
  29. package/src/core/providerLauncher/workspaceConfig.ts +41 -26
  30. package/src/core/providerRuntime/anthropic.ts +7 -1
  31. package/src/core/providerRuntime/antigravity.ts +305 -0
  32. package/src/core/providerRuntime/claudeCodeDiscovery.ts +268 -22
  33. package/src/core/providerRuntime/claudeCodeDiscoveryDebug.ts +55 -0
  34. package/src/core/providerRuntime/contextMetadata.ts +12 -24
  35. package/src/core/providerRuntime/local.ts +129 -51
  36. package/src/core/providerRuntime/models.ts +22 -11
  37. package/src/core/providerRuntime/registry.ts +58 -31
  38. package/src/core/providerRuntime/types.ts +19 -14
  39. package/src/core/providers/codexSubprocess.ts +2 -2
  40. package/src/core/providers/types.ts +1 -1
  41. package/src/core/{attachments.ts → shared/attachments.ts} +27 -4
  42. package/src/core/{cleanupFastFail.ts → shared/cleanupFastFail.ts} +1 -1
  43. package/src/core/{hollowResponseFormat.ts → shared/hollowResponseFormat.ts} +1 -1
  44. package/src/core/terminal/clearFrameBoundary.ts +814 -0
  45. package/src/core/terminal/frameLock.ts +109 -0
  46. package/src/core/terminal/inkRenderReset.ts +123 -0
  47. package/src/core/terminal/terminalControl.ts +22 -0
  48. package/src/core/terminal/terminalTitle.ts +16 -102
  49. package/src/core/version/channel.ts +23 -0
  50. package/src/core/version/updateCheck.ts +193 -0
  51. package/src/core/{launchContext.ts → workspace/launchContext.ts} +32 -39
  52. package/src/core/{planStorage.ts → workspace/planStorage.ts} +2 -2
  53. package/src/core/{workspaceGuard.ts → workspace/workspaceGuard.ts} +97 -13
  54. package/src/headless/execRunner.ts +2 -2
  55. package/src/index.tsx +43 -89
  56. package/src/session/appSession.ts +10 -7
  57. package/src/session/chatLifecycle.ts +1 -1
  58. package/src/session/liveRenderScheduler.ts +1 -1
  59. package/src/session/types.ts +3 -2
  60. package/src/ui/ActionRequiredBlock.tsx +5 -5
  61. package/src/ui/ActivityBars.tsx +3 -3
  62. package/src/ui/ActivityIndicator.tsx +6 -6
  63. package/src/ui/AgentBlock.tsx +6 -6
  64. package/src/ui/AnimatedStatusText.tsx +1 -1
  65. package/src/ui/AppShell.tsx +670 -719
  66. package/src/ui/AttachmentImportPanel.tsx +8 -8
  67. package/src/ui/AuthPanel.tsx +20 -20
  68. package/src/ui/BottomComposer.tsx +158 -118
  69. package/src/ui/DashCard.tsx +3 -3
  70. package/src/ui/Markdown.tsx +17 -17
  71. package/src/ui/ModelPickerScreen.tsx +222 -42
  72. package/src/ui/ModelReasoningPicker.tsx +15 -15
  73. package/src/ui/Panel.tsx +3 -3
  74. package/src/ui/PlanActionPicker.tsx +6 -6
  75. package/src/ui/PlanReviewPanel.tsx +9 -9
  76. package/src/ui/ProviderPicker.tsx +735 -321
  77. package/src/ui/RunFooter.tsx +3 -3
  78. package/src/ui/RuntimeStatusBar.tsx +108 -0
  79. package/src/ui/SelectionPanel.tsx +8 -4
  80. package/src/ui/SettingsPanel.tsx +9 -9
  81. package/src/ui/Spinner.tsx +1 -1
  82. package/src/ui/TextEntryPanel.tsx +11 -11
  83. package/src/ui/ThinkingBlock.tsx +8 -8
  84. package/src/ui/Timeline.tsx +1625 -1472
  85. package/src/ui/TopHeader.tsx +437 -293
  86. package/src/ui/TranscriptShell.tsx +322 -0
  87. package/src/ui/TurnGroup.tsx +33 -33
  88. package/src/ui/UpdateAvailableCard.tsx +41 -0
  89. package/src/ui/UpdatePromptPanel.tsx +197 -0
  90. package/src/ui/focus.ts +3 -0
  91. package/src/ui/layout.ts +299 -25
  92. package/src/ui/layoutListWindow.ts +145 -0
  93. package/src/ui/logoVariants.ts +103 -0
  94. package/src/ui/modeDisplay.ts +12 -12
  95. package/src/ui/runtimeDisplay.ts +112 -0
  96. package/src/ui/textLayout.ts +15 -4
  97. package/src/ui/theme.tsx +274 -395
  98. package/src/ui/timelineMeasure.ts +218 -136
  99. package/scripts/audit-codexa-capabilities.mjs +0 -466
  100. package/scripts/gen-build-info.mjs +0 -33
  101. package/scripts/smoke-terminal-bench.mjs +0 -35
  102. package/src/appRenderStability.test.ts +0 -131
  103. package/src/commands/handler.test.ts +0 -655
  104. package/src/config/launchArgs.test.ts +0 -189
  105. package/src/config/layeredConfig.test.ts +0 -143
  106. package/src/config/persistence.test.ts +0 -114
  107. package/src/config/runtimeConfig.test.ts +0 -218
  108. package/src/config/settings.test.ts +0 -155
  109. package/src/config/trustStore.test.ts +0 -29
  110. package/src/core/attachments.test.ts +0 -155
  111. package/src/core/auth/codexAuth.test.ts +0 -68
  112. package/src/core/cleanupFastFail.test.ts +0 -76
  113. package/src/core/codex.ts +0 -124
  114. package/src/core/codexExecArgs.test.ts +0 -195
  115. package/src/core/codexLaunch.test.ts +0 -205
  116. package/src/core/codexPrompt.test.ts +0 -252
  117. package/src/core/executables/codexExecutable.test.ts +0 -212
  118. package/src/core/executables/executableResolver.test.ts +0 -129
  119. package/src/core/executables/geminiExecutable.test.ts +0 -116
  120. package/src/core/executables/pathSanityScan.test.ts +0 -47
  121. package/src/core/githubDiagnostics.test.ts +0 -92
  122. package/src/core/hollowResponseFormat.test.ts +0 -58
  123. package/src/core/launchContext.test.ts +0 -157
  124. package/src/core/models/codexCapabilities.test.ts +0 -45
  125. package/src/core/models/codexModelCapabilities.test.ts +0 -246
  126. package/src/core/models/modelSpecs.test.ts +0 -283
  127. package/src/core/perf/renderDebug.test.ts +0 -230
  128. package/src/core/planStorage.test.ts +0 -143
  129. package/src/core/process/CommandRunner.test.ts +0 -105
  130. package/src/core/projectInstructions.test.ts +0 -50
  131. package/src/core/providerLauncher/launcher.test.ts +0 -238
  132. package/src/core/providerLauncher/registry.test.ts +0 -324
  133. package/src/core/providerLauncher/workspaceConfig.test.ts +0 -638
  134. package/src/core/providerRuntime/anthropic.test.ts +0 -1120
  135. package/src/core/providerRuntime/capabilityProfile.test.ts +0 -311
  136. package/src/core/providerRuntime/contextMetadata.test.ts +0 -468
  137. package/src/core/providerRuntime/gemini.test.ts +0 -437
  138. package/src/core/providerRuntime/lmstudio.test.ts +0 -168
  139. package/src/core/providerRuntime/local.test.ts +0 -787
  140. package/src/core/providerRuntime/registry.test.ts +0 -233
  141. package/src/core/providers/codexJsonStream.test.ts +0 -148
  142. package/src/core/providers/codexSubprocess.test.ts +0 -68
  143. package/src/core/providers/codexTranscript.test.ts +0 -284
  144. package/src/core/terminal/startupClear.test.ts +0 -55
  145. package/src/core/terminal/terminalCapabilities.test.ts +0 -93
  146. package/src/core/terminal/terminalControl.test.ts +0 -75
  147. package/src/core/terminal/terminalSanitize.test.ts +0 -22
  148. package/src/core/terminal/terminalSelection.test.ts +0 -42
  149. package/src/core/terminal/terminalTitle.test.ts +0 -328
  150. package/src/core/updateCheck.test.ts +0 -194
  151. package/src/core/updateCheck.ts +0 -172
  152. package/src/core/workspaceActivity.test.ts +0 -163
  153. package/src/core/workspaceGuard.test.ts +0 -151
  154. package/src/core/workspaceRoot.test.ts +0 -23
  155. package/src/exec.test.ts +0 -13
  156. package/src/headless/execArgs.test.ts +0 -147
  157. package/src/headless/execRunner.test.ts +0 -436
  158. package/src/index.test.tsx +0 -620
  159. package/src/session/appSession.test.ts +0 -897
  160. package/src/session/chatLifecycle.test.ts +0 -64
  161. package/src/session/liveRenderScheduler.test.ts +0 -201
  162. package/src/session/planFlow.test.ts +0 -103
  163. package/src/session/planTranscript.test.ts +0 -65
  164. package/src/session/promptRunSchedule.test.ts +0 -36
  165. package/src/ui/ActivityIndicator.test.tsx +0 -58
  166. package/src/ui/AgentBlock.test.ts +0 -6
  167. package/src/ui/AnimatedStatusText.test.ts +0 -16
  168. package/src/ui/AppShell.test.tsx +0 -1776
  169. package/src/ui/AttachmentImportPanel.test.tsx +0 -204
  170. package/src/ui/BottomComposer.test.ts +0 -674
  171. package/src/ui/CodexLogo.tsx +0 -55
  172. package/src/ui/Markdown.test.ts +0 -157
  173. package/src/ui/ModelPickerProviderScope.test.tsx +0 -411
  174. package/src/ui/ModelPickerScreen.test.tsx +0 -99
  175. package/src/ui/ModelPickerState.test.tsx +0 -151
  176. package/src/ui/ModelReasoningPicker.test.tsx +0 -447
  177. package/src/ui/PlanReviewPanel.test.tsx +0 -267
  178. package/src/ui/PromptCardBorder.test.tsx +0 -161
  179. package/src/ui/ProviderPicker.test.tsx +0 -289
  180. package/src/ui/ProviderShortcut.test.tsx +0 -143
  181. package/src/ui/SettingsPanel.test.tsx +0 -233
  182. package/src/ui/StaticTranscriptItem.tsx +0 -56
  183. package/src/ui/Timeline.test.ts +0 -2067
  184. package/src/ui/TimelineNavigation.test.tsx +0 -201
  185. package/src/ui/TopHeader.test.tsx +0 -254
  186. package/src/ui/TurnGroup.test.tsx +0 -365
  187. package/src/ui/busyStatusAnimation.test.ts +0 -30
  188. package/src/ui/commandNormalize.test.ts +0 -142
  189. package/src/ui/diffRenderer.test.ts +0 -102
  190. package/src/ui/focusFlow.test.tsx +0 -1098
  191. package/src/ui/inputBuffer.test.ts +0 -151
  192. package/src/ui/layout.test.ts +0 -146
  193. package/src/ui/modeDisplay.test.ts +0 -42
  194. package/src/ui/runActivityView.test.ts +0 -89
  195. package/src/ui/runLifecycleView.test.tsx +0 -237
  196. package/src/ui/statusRenderIsolation.test.tsx +0 -654
  197. package/src/ui/terminalAnswerFormat.test.ts +0 -19
  198. package/src/ui/textLayout.test.ts +0 -18
  199. package/src/ui/themeFlow.test.ts +0 -53
  200. package/src/ui/timelineMeasureCache.test.ts +0 -986
  201. /package/src/core/{inputDebug.ts → debug/inputDebug.ts} +0 -0
  202. /package/src/core/{clipboard.ts → shared/clipboard.ts} +0 -0
  203. /package/src/core/{githubDiagnostics.ts → shared/githubDiagnostics.ts} +0 -0
  204. /package/src/core/{projectInstructions.ts → workspace/projectInstructions.ts} +0 -0
  205. /package/src/core/{workspaceActivity.ts → workspace/workspaceActivity.ts} +0 -0
  206. /package/src/core/{workspaceRoot.ts → workspace/workspaceRoot.ts} +0 -0
@@ -1,321 +1,735 @@
1
- import React, { useMemo, useState } from "react";
2
- import { Box, Text, useFocus, useInput } from "ink";
3
- import type { ProviderConfig, ProviderId, ProviderPickerAction } from "../core/providerLauncher/types.js";
4
- import { traceInputDebug } from "../core/inputDebug.js";
5
- import { FOCUS_IDS } from "./focus.js";
6
- import { clampVisualText, getShellWidth, type Layout } from "./layout.js";
7
- import { useTheme } from "./theme.js";
8
-
9
- // ─── Types & helpers ─────────────────────────────────────────────────────────
10
-
11
- interface ProviderPickerProps {
12
- layout: Layout;
13
- providers: readonly ProviderConfig[];
14
- onAction: (providerId: ProviderId, action: ProviderPickerAction) => void;
15
- onCancel: () => void;
16
- /** When set, the picker mounts directly at this provider's action panel. */
17
- initialProviderId?: ProviderId;
18
- }
19
-
20
- interface ProviderActionItem {
21
- value: ProviderPickerAction;
22
- label: string;
23
- disabledReason?: string | null;
24
- }
25
-
26
- function clampIndex(index: number, length: number): number {
27
- if (length <= 0) return 0;
28
- return Math.max(0, Math.min(length - 1, index));
29
- }
30
-
31
- // ─── Component ────────────────────────────────────────────────────────────────
32
-
33
- export function ProviderPicker({ layout, providers, onAction, onCancel, initialProviderId }: ProviderPickerProps) {
34
- const theme = useTheme();
35
- const { isFocused } = useFocus({ id: FOCUS_IDS.providerPicker, autoFocus: true });
36
- const initialIndex = initialProviderId
37
- ? Math.max(0, providers.findIndex((p) => p.id === initialProviderId))
38
- : 0;
39
- const [providerIndex, setProviderIndex] = useState(initialIndex);
40
- const [mode, setMode] = useState<"providers" | "actions">(
41
- initialProviderId ? "actions" : "providers",
42
- );
43
- const [actionIndex, setActionIndex] = useState(0);
44
-
45
- const selectedProvider = providers[clampIndex(providerIndex, providers.length)];
46
- const shellWidth = getShellWidth(layout.cols);
47
- const panelWidth = Math.max(42, Math.min(shellWidth - 2, layout.mode === "full" ? 86 : 72));
48
- const innerWidth = Math.max(30, panelWidth - 4);
49
- const markerWidth = 2;
50
- const columnGaps = 4;
51
- const columnWidthBudget = Math.max(26, innerWidth - markerWidth - columnGaps);
52
- const statusWidth = Math.min(8, Math.max(6, columnWidthBudget - 20));
53
- const toolsWidth = 4;
54
- const streamWidth = 4;
55
- const contextWidth = Math.min(11, Math.max(7, columnWidthBudget - statusWidth - toolsWidth - streamWidth - 16));
56
- const providerNameWidth = Math.min(layout.mode === "micro" ? 10 : 14, Math.max(8, columnWidthBudget - statusWidth - toolsWidth - streamWidth - contextWidth - 8));
57
- const modelWidth = Math.max(5, columnWidthBudget - providerNameWidth - contextWidth - toolsWidth - streamWidth - statusWidth);
58
-
59
- const helpText = layout.mode === "micro"
60
- ? "Enter select U use S default Esc"
61
- : "Enter = select, U = use, S = set default, Esc = cancel";
62
- const title = mode === "actions" && selectedProvider
63
- ? `Provider action: ${selectedProvider.displayName}`
64
- : "Providers";
65
- const actions = useMemo<ProviderActionItem[]>(() => {
66
- const routeUnavailable = selectedProvider?.routeMode === "in-codexa"
67
- ? null
68
- : selectedProvider?.routeUnavailableReason ?? "In-Codexa routing is not configured yet.";
69
-
70
- return [
71
- { value: "use-in-codexa", label: "Use in Codexa", disabledReason: routeUnavailable },
72
- { value: "select-model", label: "Select model", disabledReason: routeUnavailable },
73
- { value: "refresh-models", label: selectedProvider?.id === "anthropic" ? "Refresh Claude capabilities" : selectedProvider?.id === "local" ? "Refresh LM Studio metadata" : "Refresh models", disabledReason: routeUnavailable },
74
- ...(selectedProvider?.id === "google" || selectedProvider?.id === "local"
75
- ? [{ value: "run-diagnostics" as const, label: selectedProvider.id === "local" ? "Run Local diagnostics" : "Run Gemini diagnostics" }]
76
- : []),
77
- { value: "launch", label: "Launch external CLI" },
78
- { value: "set-default", label: "Set as workspace default" },
79
- { value: "cancel", label: "Cancel" },
80
- ];
81
- }, [selectedProvider]);
82
-
83
- useInput((input, key) => {
84
- traceInputDebug("provider_picker_input", {
85
- handler: "ProviderPicker.useInput",
86
- input,
87
- return: Boolean(key.return),
88
- escape: Boolean(key.escape),
89
- upArrow: Boolean(key.upArrow),
90
- downArrow: Boolean(key.downArrow),
91
- mode,
92
- providerIndex,
93
- actionIndex,
94
- });
95
-
96
- if (key.ctrl && (input === "c" || input === "q")) {
97
- onCancel();
98
- return;
99
- }
100
-
101
- if (key.escape) {
102
- if (mode === "actions") {
103
- setMode("providers");
104
- setActionIndex(0);
105
- return;
106
- }
107
- onCancel();
108
- return;
109
- }
110
-
111
- if (mode === "providers") {
112
- if (key.upArrow || input === "k") {
113
- setProviderIndex((current) => clampIndex(current - 1, providers.length));
114
- return;
115
- }
116
- if (key.downArrow || input === "j") {
117
- setProviderIndex((current) => clampIndex(current + 1, providers.length));
118
- return;
119
- }
120
- if (input.toLowerCase() === "s" && selectedProvider) {
121
- onAction(selectedProvider.id, "set-default");
122
- return;
123
- }
124
- if (input.toLowerCase() === "u" && selectedProvider) {
125
- onAction(selectedProvider.id, "use-in-codexa");
126
- return;
127
- }
128
- if (key.return && selectedProvider) {
129
- setMode("actions");
130
- setActionIndex(0);
131
- }
132
- return;
133
- }
134
-
135
- if (key.upArrow || input === "k") {
136
- setActionIndex((current) => clampIndex(current - 1, actions.length));
137
- return;
138
- }
139
- if (key.downArrow || input === "j") {
140
- setActionIndex((current) => clampIndex(current + 1, actions.length));
141
- return;
142
- }
143
- if (key.return && selectedProvider) {
144
- onAction(selectedProvider.id, actions[actionIndex]?.value ?? "cancel");
145
- }
146
- }, { isActive: isFocused });
147
-
148
- const body = useMemo(() => {
149
- if (mode === "actions" && selectedProvider) {
150
- const inCodexaAvailable = selectedProvider.routeMode === "in-codexa";
151
- const isConfigured = inCodexaAvailable && !selectedProvider.routeUnavailableReason;
152
- const inCodexaStatusText = !inCodexaAvailable ? "Unavailable" : isConfigured ? "Available" : "Needs configuration";
153
- const inCodexaStatusColor = !inCodexaAvailable ? theme.ERROR : isConfigured ? theme.SUCCESS : theme.WARNING;
154
-
155
- return (
156
- <Box flexDirection="column">
157
- <Box marginBottom={1} flexDirection="column" paddingX={2}>
158
- <Text color={theme.DIM}>Status: <Text color={theme.TEXT}>{selectedProvider.routeUnavailableReason ?? "Ready"}</Text></Text>
159
- <Text color={theme.DIM}>Backend: <Text color={theme.TEXT}>{selectedProvider.backendType}</Text></Text>
160
- <Text color={theme.DIM}>Use in Codexa: <Text color={inCodexaStatusColor}>{inCodexaStatusText}</Text></Text>
161
- </Box>
162
- {actions.map((action, index) => (
163
- <ActionRow
164
- key={action.value}
165
- label={action.label}
166
- disabledReason={action.disabledReason}
167
- isHighlighted={index === actionIndex}
168
- width={innerWidth}
169
- />
170
- ))}
171
- </Box>
172
- );
173
- }
174
-
175
- return providers.map((provider, index) => (
176
- <ProviderRow
177
- key={provider.id}
178
- provider={provider}
179
- isHighlighted={index === providerIndex}
180
- widths={{ providerNameWidth, modelWidth, contextWidth, toolsWidth, streamWidth, statusWidth }}
181
- />
182
- ));
183
- }, [actionIndex, actions, contextWidth, innerWidth, mode, modelWidth, providerIndex, providerNameWidth, providers, streamWidth, toolsWidth, statusWidth]);
184
-
185
- return (
186
- <Box flexDirection="column" width={panelWidth}>
187
- <Box
188
- borderStyle="round"
189
- borderColor={theme.PROMPT}
190
- paddingX={1}
191
- paddingY={0}
192
- width={panelWidth}
193
- flexDirection="column"
194
- >
195
- <Box width="100%" overflow="hidden">
196
- <Text color={theme.ACCENT} bold>
197
- {clampVisualText(`${title} ${helpText}`, innerWidth)}
198
- </Text>
199
- </Box>
200
-
201
- {mode === "providers" && (
202
- <Box width="100%" overflow="hidden">
203
- <Text color={theme.DIM}>
204
- {" "}
205
- {clampVisualText("Provider", providerNameWidth)}
206
- {" "}
207
- {clampVisualText("Model", modelWidth)}
208
- {" "}
209
- {clampVisualText("Context", contextWidth)}
210
- {" "}
211
- {clampVisualText("Tool", toolsWidth)}
212
- {" "}
213
- {clampVisualText("Strm", streamWidth)}
214
- {" "}
215
- {clampVisualText("Status", statusWidth)}
216
- </Text>
217
- </Box>
218
- )}
219
-
220
- <Box flexDirection="column" marginTop={0} width="100%">
221
- {body}
222
- </Box>
223
- </Box>
224
- </Box>
225
- );
226
- }
227
-
228
- // ─── Subcomponents ───────────────────────────────────────────────────────────
229
-
230
- function capabilityFlag(value: boolean | null | undefined): string {
231
- if (value === true) return "Y";
232
- if (value === false) return "N";
233
- return "?";
234
- }
235
-
236
- function ProviderRow({
237
- provider,
238
- isHighlighted,
239
- widths,
240
- }: {
241
- provider: ProviderConfig;
242
- isHighlighted: boolean;
243
- widths: {
244
- providerNameWidth: number;
245
- modelWidth: number;
246
- contextWidth: number;
247
- toolsWidth: number;
248
- streamWidth: number;
249
- statusWidth: number;
250
- };
251
- }) {
252
- const theme = useTheme();
253
- const statusColor = provider.isActiveRoute
254
- ? theme.SUCCESS
255
- : provider.enabled && !provider.routeUnavailableReason
256
- ? theme.SUCCESS
257
- : theme.WARNING;
258
- const marker = isHighlighted ? ">" : " ";
259
- const defaultMark = provider.isActiveRoute ? "@" : provider.isDefault ? "*" : " ";
260
- const statusText = provider.isActiveRoute ? "Active" : provider.statusLabel;
261
-
262
- return (
263
- <Box width="100%" overflow="hidden">
264
- <Box width={2} flexShrink={0}>
265
- <Text color={isHighlighted ? theme.ACCENT : theme.DIM}>{marker}{defaultMark}</Text>
266
- </Box>
267
- <Box width={widths.providerNameWidth} flexShrink={0} overflow="hidden">
268
- <Text color={isHighlighted ? theme.TEXT : theme.MUTED} bold={isHighlighted}>
269
- {clampVisualText(provider.displayName, widths.providerNameWidth)}
270
- </Text>
271
- </Box>
272
- <Text> </Text>
273
- <Box width={widths.modelWidth} flexShrink={0} overflow="hidden">
274
- <Text color={theme.MUTED}>{clampVisualText(provider.currentModel, widths.modelWidth)}</Text>
275
- </Box>
276
- <Text> </Text>
277
- <Box width={widths.contextWidth} flexShrink={0} overflow="hidden">
278
- <Text color={theme.MUTED}>{clampVisualText(provider.contextLengthLabel ?? "Unknown", widths.contextWidth)}</Text>
279
- </Box>
280
- <Text> </Text>
281
- <Box width={widths.toolsWidth} flexShrink={0} overflow="hidden">
282
- <Text color={theme.MUTED}>{clampVisualText(capabilityFlag(provider.capabilityProfile?.supportsToolCalls), widths.toolsWidth)}</Text>
283
- </Box>
284
- <Text> </Text>
285
- <Box width={widths.streamWidth} flexShrink={0} overflow="hidden">
286
- <Text color={theme.MUTED}>{clampVisualText(capabilityFlag(provider.capabilityProfile?.supportsStreaming), widths.streamWidth)}</Text>
287
- </Box>
288
- <Text> </Text>
289
- <Box width={widths.statusWidth} flexShrink={0} overflow="hidden">
290
- <Text color={statusColor}>{clampVisualText(statusText, widths.statusWidth)}</Text>
291
- </Box>
292
- </Box>
293
- );
294
- }
295
-
296
- function ActionRow({
297
- label,
298
- disabledReason,
299
- isHighlighted,
300
- width,
301
- }: {
302
- label: string;
303
- disabledReason?: string | null;
304
- isHighlighted: boolean;
305
- width: number;
306
- }) {
307
- const theme = useTheme();
308
- const text = disabledReason ? `${label} unavailable` : label;
309
- return (
310
- <Box width="100%" overflow="hidden">
311
- <Box width={2} flexShrink={0}>
312
- <Text color={isHighlighted ? theme.ACCENT : theme.DIM}>{isHighlighted ? ">" : " "}</Text>
313
- </Box>
314
- <Box width={Math.max(10, width - 2)} flexShrink={0} overflow="hidden">
315
- <Text color={disabledReason ? theme.DIM : isHighlighted ? theme.TEXT : theme.MUTED} bold={isHighlighted && !disabledReason}>
316
- {clampVisualText(text, Math.max(10, width - 2))}
317
- </Text>
318
- </Box>
319
- </Box>
320
- );
321
- }
1
+ import React, { useMemo, useState } from "react";
2
+ import { Box, Text, useFocus, useInput } from "ink";
3
+ import type { ProviderConfig, ProviderId, ProviderPickerAction } from "../core/providerLauncher/types.js";
4
+ import { traceInputDebug } from "../core/debug/inputDebug.js";
5
+ import { FOCUS_IDS } from "./focus.js";
6
+ import {
7
+ clampVisualText,
8
+ getShellWidth,
9
+ type Layout,
10
+ usePanelAvailableRows,
11
+ getAvailableRowsForPanel,
12
+ useAppLayoutBudget,
13
+ useActivePanelLayout,
14
+ type ActivePanelLayout,
15
+ type PanelLayout,
16
+ usePanelLayout,
17
+ } from "./layout.js";
18
+ import { calculateListWindow } from "./layoutListWindow.js";
19
+ import { useTheme } from "./theme.js";
20
+
21
+ // ─── Types & helpers ─────────────────────────────────────────────────────────
22
+
23
+ interface ProviderPickerProps {
24
+ layout?: Layout;
25
+ providers: readonly ProviderConfig[];
26
+ onAction: (providerId: ProviderId, action: ProviderPickerAction) => void;
27
+ onCancel: () => void;
28
+ /** When set, the picker mounts directly at this provider's action panel. */
29
+ initialProviderId?: ProviderId;
30
+ availableRows?: number;
31
+ activePanelLayout?: ActivePanelLayout;
32
+ panelLayout?: PanelLayout;
33
+ }
34
+
35
+ interface ProviderActionItem {
36
+ value: ProviderPickerAction;
37
+ label: string;
38
+ disabledReason?: string | null;
39
+ }
40
+
41
+ function clampIndex(index: number, length: number): number {
42
+ if (length <= 0) return 0;
43
+ return Math.max(0, Math.min(length - 1, index));
44
+ }
45
+
46
+ export function getTableLayout(innerWidth: number, isMicro = false) {
47
+ if (innerWidth < 70) {
48
+ const columnWidthBudget = Math.max(26, innerWidth - 5 - 4);
49
+ const status = Math.min(8, Math.max(6, columnWidthBudget - 20));
50
+ const tool = innerWidth < 65 ? 0 : 4;
51
+ const stream = innerWidth < 65 ? 0 : 4;
52
+ const context = innerWidth < 45 ? 0 : Math.min(11, Math.max(10, columnWidthBudget - status - (tool || 4) - (stream || 4) - 16));
53
+ const provider = Math.min(isMicro ? 10 : 14, Math.max(8, columnWidthBudget - status - tool - stream - context - 8));
54
+ const model = Math.max(5, columnWidthBudget - provider - context - tool - stream - status);
55
+ const trailingPadding = 0;
56
+ return { provider, model, context, tool, stream, status, trailingPadding };
57
+ }
58
+
59
+ const provider = Math.min(22, Math.max(12, Math.floor(innerWidth * 0.18)));
60
+ const status = Math.min(20, Math.max(12, Math.floor(innerWidth * 0.15)));
61
+ const context = Math.min(14, Math.max(11, Math.floor(innerWidth * 0.12)));
62
+ const tool = 4;
63
+ const stream = 4;
64
+ const fixed = 5 + provider + 1 + context + 1 + tool + 1 + stream + 1 + status;
65
+ const model = Math.max(10, innerWidth - fixed - 3);
66
+ const trailingPadding = Math.max(0, innerWidth - (fixed + 1 + model + 1));
67
+
68
+ return {
69
+ provider,
70
+ model,
71
+ context,
72
+ tool,
73
+ stream,
74
+ status,
75
+ trailingPadding
76
+ };
77
+ }
78
+
79
+ // ─── Component ────────────────────────────────────────────────────────────────
80
+
81
+ export function ProviderPicker({
82
+ layout,
83
+ providers,
84
+ onAction,
85
+ onCancel,
86
+ initialProviderId,
87
+ availableRows: propAvailableRows,
88
+ activePanelLayout,
89
+ panelLayout,
90
+ }: ProviderPickerProps) {
91
+ const theme = useTheme();
92
+ const budget = useAppLayoutBudget();
93
+ const { isFocused } = useFocus({ id: FOCUS_IDS.providerPicker, autoFocus: true });
94
+ const initialIndex = initialProviderId
95
+ ? Math.max(0, providers.findIndex((p) => p.id === initialProviderId))
96
+ : 0;
97
+ const [providerIndex, setProviderIndex] = useState(initialIndex);
98
+ const [mode, setMode] = useState<"providers" | "actions">(
99
+ initialProviderId ? "actions" : "providers",
100
+ );
101
+ const [actionIndex, setActionIndex] = useState(0);
102
+
103
+ const contextLayout = useActivePanelLayout();
104
+ const activeLayout = (activePanelLayout ?? contextLayout) as ActivePanelLayout | undefined;
105
+
106
+ const selectedProvider = providers[clampIndex(providerIndex, providers.length)];
107
+ const shellWidth = getShellWidth(layout?.cols ?? 120);
108
+ const panelWidth = activeLayout
109
+ ? activeLayout.width
110
+ : Math.max(
111
+ 42,
112
+ Math.min((layout as any)?.contentWidth ?? (shellWidth - 2), shellWidth - 2)
113
+ );
114
+
115
+ const hookPanelLayout = usePanelLayout();
116
+ const hookAvailableRows = usePanelAvailableRows();
117
+ const resolvedPanelLayout = useMemo<PanelLayout>(() => {
118
+ if (panelLayout) return panelLayout;
119
+ if (hookPanelLayout) return hookPanelLayout;
120
+
121
+ const mode = layout?.mode ?? "regular";
122
+ const resolvedRows = activeLayout
123
+ ? activeLayout.availableRows
124
+ : getAvailableRowsForPanel(layout || { cols: 120, rows: 24, mode: "regular" }, propAvailableRows ?? hookAvailableRows);
125
+ const resolvedCols = activeLayout
126
+ ? activeLayout.availableCols
127
+ : Math.max(30, shellWidth - 4);
128
+
129
+ return {
130
+ mode: (mode === "compact" || mode === "micro" as any) ? "compact" : mode === "expanded" || mode === "max" as any || mode === "wide" as any ? "expanded" : "regular",
131
+ availableRows: resolvedRows,
132
+ availableCols: resolvedCols,
133
+ };
134
+ }, [panelLayout, hookPanelLayout, layout, activeLayout, propAvailableRows, shellWidth, hookAvailableRows]);
135
+
136
+ const availableRows = resolvedPanelLayout.availableRows;
137
+ const innerWidth = resolvedPanelLayout.availableCols;
138
+
139
+ const isCompactLayout = resolvedPanelLayout.mode === "compact";
140
+
141
+ // Compact columns widths:
142
+ const markerWidth = 5;
143
+ const compactContextWidth = innerWidth >= 90 ? 6 : 5;
144
+ const compactStatusWidth = innerWidth >= 90 ? 8 : 6;
145
+ const compactProviderWidth = Math.max(11, Math.min(14, Math.floor(innerWidth * 0.15)));
146
+ const spacingWidth = 4;
147
+ const fixedWidth = markerWidth + compactProviderWidth + compactContextWidth + compactStatusWidth + spacingWidth;
148
+ const compactModelWidth = Math.max(12, innerWidth - fixedWidth);
149
+ const compactWidths = {
150
+ markerWidth,
151
+ providerWidth: compactProviderWidth,
152
+ modelWidth: compactModelWidth,
153
+ contextWidth: compactContextWidth,
154
+ statusWidth: compactStatusWidth
155
+ };
156
+
157
+ // Regular columns:
158
+ const cols = getTableLayout(innerWidth, false);
159
+ const providerNameWidth = cols.provider;
160
+ const modelWidth = cols.model;
161
+ const contextWidth = cols.context;
162
+ const toolsWidth = cols.tool;
163
+ const streamWidth = cols.stream;
164
+ const statusWidth = cols.status;
165
+
166
+ const helpText = isCompactLayout
167
+ ? "Enter select | U use | S default | Esc close"
168
+ : "Enter = select, U = use, S = set default, Esc = cancel";
169
+
170
+ const actions = useMemo<ProviderActionItem[]>(() => {
171
+ const routeUnavailable = selectedProvider?.routeMode === "in-codexa";
172
+ const disabledReason = routeUnavailable
173
+ ? null
174
+ : selectedProvider?.routeUnavailableReason ?? "In-Codexa routing is not configured yet.";
175
+
176
+ return [
177
+ { value: "use-in-codexa", label: "Use in Codexa", disabledReason },
178
+ { value: "select-model", label: "Select model", disabledReason },
179
+ { value: "refresh-models", label: selectedProvider?.id === "anthropic" ? "Refresh Claude capabilities" : selectedProvider?.id === "local" ? "Refresh LM Studio metadata" : "Refresh models", disabledReason },
180
+ ...(selectedProvider?.id === "google" || selectedProvider?.id === "local"
181
+ ? [{ value: "run-diagnostics" as const, label: selectedProvider.id === "local" ? "Run Local diagnostics" : "Run Gemini diagnostics" }]
182
+ : []),
183
+ { value: "launch", label: "Launch external CLI" },
184
+ { value: "set-default", label: "Set as workspace default" },
185
+ { value: "cancel", label: "Cancel" },
186
+ ];
187
+ }, [selectedProvider]);
188
+
189
+ useInput((input, key) => {
190
+ traceInputDebug("provider_picker_input", {
191
+ handler: "ProviderPicker.useInput",
192
+ input,
193
+ return: Boolean(key.return),
194
+ escape: Boolean(key.escape),
195
+ upArrow: Boolean(key.upArrow),
196
+ downArrow: Boolean(key.downArrow),
197
+ mode,
198
+ providerIndex,
199
+ actionIndex,
200
+ });
201
+
202
+ if (key.ctrl && (input === "c" || input === "q")) {
203
+ onCancel();
204
+ return;
205
+ }
206
+
207
+ if (key.escape) {
208
+ if (mode === "actions") {
209
+ setMode("providers");
210
+ setActionIndex(0);
211
+ return;
212
+ }
213
+ onCancel();
214
+ return;
215
+ }
216
+
217
+ if (mode === "providers") {
218
+ if (key.upArrow || input === "k") {
219
+ setProviderIndex((current) => clampIndex(current - 1, providers.length));
220
+ return;
221
+ }
222
+ if (key.downArrow || input === "j") {
223
+ setProviderIndex((current) => clampIndex(current + 1, providers.length));
224
+ return;
225
+ }
226
+ if (input.toLowerCase() === "s" && selectedProvider) {
227
+ onAction(selectedProvider.id, "set-default");
228
+ return;
229
+ }
230
+ if (input.toLowerCase() === "u" && selectedProvider) {
231
+ onAction(selectedProvider.id, "use-in-codexa");
232
+ return;
233
+ }
234
+ if (key.return && selectedProvider) {
235
+ setMode("actions");
236
+ setActionIndex(0);
237
+ }
238
+ return;
239
+ }
240
+
241
+ if (key.upArrow || input === "k") {
242
+ setActionIndex((current) => clampIndex(current - 1, actions.length));
243
+ return;
244
+ }
245
+ if (key.downArrow || input === "j") {
246
+ setActionIndex((current) => clampIndex(current + 1, actions.length));
247
+ return;
248
+ }
249
+ if (key.return && selectedProvider) {
250
+ onAction(selectedProvider.id, actions[actionIndex]?.value ?? "cancel");
251
+ }
252
+ }, { isActive: isFocused });
253
+
254
+ const activeRouteIndex = providers.findIndex((p) => p.isActiveRoute);
255
+
256
+ // ─── Layout & Windowing ───────────────────────────────────────────────────
257
+
258
+ const windowResult = useMemo(() => {
259
+ if (mode !== "providers") return null;
260
+
261
+ if (resolvedPanelLayout.mode === "compact") {
262
+ const titleRows = 1;
263
+ const hasActiveRoute = activeRouteIndex >= 0;
264
+
265
+ let footerRows = providers.length > resolvedPanelLayout.availableRows - titleRows ? 1 : 0;
266
+ let visibleRows = Math.max(1, resolvedPanelLayout.availableRows - titleRows - footerRows);
267
+
268
+ let start = clampIndex(
269
+ providerIndex - Math.floor(visibleRows / 2),
270
+ providers.length
271
+ );
272
+ let adjustedStart = Math.max(0, Math.min(start, providers.length - visibleRows));
273
+ let end = Math.min(providers.length, adjustedStart + visibleRows);
274
+
275
+ let reserveCurrent = false;
276
+ if (hasActiveRoute && visibleRows < providers.length) {
277
+ const isOutside = activeRouteIndex < adjustedStart || activeRouteIndex >= end;
278
+ if (isOutside) {
279
+ reserveCurrent = true;
280
+ footerRows = providers.length > resolvedPanelLayout.availableRows - titleRows - 1 ? 1 : 0;
281
+ visibleRows = Math.max(1, resolvedPanelLayout.availableRows - titleRows - footerRows - 1);
282
+ start = clampIndex(
283
+ providerIndex - Math.floor(visibleRows / 2),
284
+ providers.length
285
+ );
286
+ adjustedStart = Math.max(0, Math.min(start, providers.length - visibleRows));
287
+ end = Math.min(providers.length, adjustedStart + visibleRows);
288
+ }
289
+ }
290
+
291
+ return {
292
+ start: adjustedStart,
293
+ end,
294
+ showAbove: adjustedStart > 0,
295
+ showBelow: end < providers.length,
296
+ showRange: providers.length > visibleRows,
297
+ showHeaders: false,
298
+ showBorder: true,
299
+ showTitle: true,
300
+ reserveCurrent,
301
+ renderMode: "compact" as const,
302
+ visibleCount: visibleRows,
303
+ };
304
+ }
305
+
306
+ if (resolvedPanelLayout.mode === "expanded" && providers.length <= 6) {
307
+ return {
308
+ start: 0,
309
+ end: providers.length,
310
+ showAbove: false,
311
+ showBelow: false,
312
+ showRange: false,
313
+ showHeaders: false,
314
+ showBorder: true,
315
+ showTitle: true,
316
+ reserveCurrent: false,
317
+ renderMode: "compact" as const,
318
+ visibleCount: providers.length,
319
+ };
320
+ }
321
+
322
+ // Determine chrome rows based on available space
323
+ let showBorder = true;
324
+ let showTitle = true;
325
+ let showHeaders = true;
326
+
327
+ if (availableRows < 7) {
328
+ showBorder = false;
329
+ showTitle = false;
330
+ showHeaders = false;
331
+ } else if (availableRows < 9) {
332
+ showHeaders = false;
333
+ }
334
+
335
+ const baseChrome = (showBorder ? 2 : 0) + (showTitle ? 1 : 0) + (showHeaders ? 1 : 0);
336
+ const hasActiveRoute = activeRouteIndex >= 0;
337
+
338
+ let window = calculateListWindow({
339
+ itemCount: providers.length,
340
+ selectedIndex: providerIndex,
341
+ availableRows,
342
+ chromeRows: baseChrome,
343
+ showRangeLine: true,
344
+ });
345
+
346
+ let reserveCurrent = false;
347
+ if (hasActiveRoute && window.visibleCount < providers.length) {
348
+ const isOutside = activeRouteIndex < window.start || activeRouteIndex >= window.end;
349
+ if (isOutside) {
350
+ reserveCurrent = true;
351
+ window = calculateListWindow({
352
+ itemCount: providers.length,
353
+ selectedIndex: providerIndex,
354
+ availableRows,
355
+ chromeRows: baseChrome + 1,
356
+ showRangeLine: true,
357
+ });
358
+ }
359
+ }
360
+
361
+ return {
362
+ ...window,
363
+ showBorder,
364
+ showTitle,
365
+ showHeaders,
366
+ reserveCurrent,
367
+ renderMode: window.visibleCount < providers.length ? ("windowed" as const) : ("full" as const),
368
+ };
369
+ }, [mode, providers.length, providerIndex, availableRows, resolvedPanelLayout, activeRouteIndex]);
370
+
371
+ const visibleProviders = useMemo(() => {
372
+ if (mode !== "providers") return [];
373
+ if (!windowResult) return providers;
374
+ return providers.slice(windowResult.start, windowResult.end);
375
+ }, [mode, providers, windowResult]);
376
+
377
+ const showCurrent = mode === "providers" && windowResult?.reserveCurrent &&
378
+ activeRouteIndex >= 0 && (activeRouteIndex < windowResult.start || activeRouteIndex >= windowResult.end);
379
+
380
+ const titleText = mode === "actions" && selectedProvider
381
+ ? `Provider action: ${selectedProvider.displayName}`
382
+ : (windowResult?.showRange)
383
+ ? `Providers · Showing ${windowResult.start + 1}-${windowResult.end} of ${providers.length}`
384
+ : "Providers";
385
+
386
+ const body = useMemo(() => {
387
+ if (mode === "actions" && selectedProvider) {
388
+ const inCodexaAvailable = selectedProvider.routeMode === "in-codexa";
389
+ const isConfigured = inCodexaAvailable && !selectedProvider.routeUnavailableReason;
390
+ const inCodexaStatusText = !inCodexaAvailable ? "Unavailable" : isConfigured ? "Available" : "Needs configuration";
391
+ const inCodexaStatusColor = !inCodexaAvailable ? theme.error : isConfigured ? theme.success : theme.warning;
392
+
393
+ if (isCompactLayout) {
394
+ const statusText = selectedProvider.routeUnavailableReason ?? "Ready";
395
+ const metadataLine = `Status: ${statusText} · Backend: ${selectedProvider.backendType} · Codexa: ${inCodexaStatusText}`;
396
+ return (
397
+ <Box flexDirection="column">
398
+ <Box marginBottom={1} flexDirection="row" paddingX={2} overflow="hidden">
399
+ <Text color={theme.textDim} wrap="truncate">
400
+ {clampVisualText(metadataLine, innerWidth - 4)}
401
+ </Text>
402
+ </Box>
403
+ {actions.map((action, index) => (
404
+ <ActionRow
405
+ key={action.value}
406
+ label={action.label}
407
+ disabledReason={action.disabledReason}
408
+ isHighlighted={index === actionIndex}
409
+ width={innerWidth}
410
+ />
411
+ ))}
412
+ </Box>
413
+ );
414
+ }
415
+
416
+ return (
417
+ <Box flexDirection="column">
418
+ <Box marginBottom={1} flexDirection="column" paddingX={2}>
419
+ <Text color={theme.textDim}>Status: <Text color={theme.text}>{selectedProvider.routeUnavailableReason ?? "Ready"}</Text></Text>
420
+ <Text color={theme.textDim}>Backend: <Text color={theme.text}>{selectedProvider.backendType}</Text></Text>
421
+ <Text color={theme.textDim}>Use in Codexa: <Text color={inCodexaStatusColor}>{inCodexaStatusText}</Text></Text>
422
+ </Box>
423
+ {actions.map((action, index) => (
424
+ <ActionRow
425
+ key={action.value}
426
+ label={action.label}
427
+ disabledReason={action.disabledReason}
428
+ isHighlighted={index === actionIndex}
429
+ width={innerWidth}
430
+ />
431
+ ))}
432
+ </Box>
433
+ );
434
+ }
435
+
436
+ if (isCompactLayout) {
437
+ return visibleProviders.map((provider, index) => (
438
+ <ProviderRowCompact
439
+ key={provider.id}
440
+ provider={provider}
441
+ isHighlighted={(windowResult!.start + index) === providerIndex}
442
+ widths={compactWidths}
443
+ />
444
+ ));
445
+ }
446
+
447
+ return visibleProviders.map((provider, index) => (
448
+ <ProviderRow
449
+ key={provider.id}
450
+ provider={provider}
451
+ isHighlighted={(windowResult!.start + index) === providerIndex}
452
+ widths={{ providerNameWidth, modelWidth, contextWidth, toolsWidth, streamWidth, statusWidth }}
453
+ />
454
+ ));
455
+ }, [actionIndex, actions, contextWidth, innerWidth, mode, modelWidth, providerIndex, providerNameWidth, visibleProviders, windowResult?.start, streamWidth, toolsWidth, statusWidth, theme, selectedProvider, isCompactLayout, compactWidths]);
456
+
457
+ const showBorder = mode === "actions" ? (availableRows >= actions.length + 7) : (windowResult?.showBorder ?? true);
458
+
459
+ return (
460
+ <Box flexDirection="column" width={panelWidth} flexShrink={0}>
461
+ <Box
462
+ borderStyle={showBorder ? "round" : undefined}
463
+ borderColor={theme.prompt}
464
+ paddingX={showBorder ? 1 : 0}
465
+ paddingY={0}
466
+ width={panelWidth}
467
+ flexDirection="column"
468
+ flexShrink={0}
469
+ >
470
+ {(windowResult?.showTitle ?? true) && (
471
+ <Box width="100%" overflow="hidden" flexShrink={0}>
472
+ <Text color={theme.accent} bold>
473
+ {clampVisualText(showBorder ? `${titleText} ${helpText}` : titleText, innerWidth)}
474
+ </Text>
475
+ </Box>
476
+ )}
477
+
478
+ {!(windowResult?.showTitle ?? true) && windowResult?.showRange && (
479
+ <Box width="100%" overflow="hidden" flexShrink={0}>
480
+ <Text color={theme.accent}>
481
+ Showing {windowResult.start + 1}-{windowResult.end} of {providers.length}
482
+ </Text>
483
+ </Box>
484
+ )}
485
+
486
+ {windowResult?.showAbove && (
487
+ <Box height={1} overflow="hidden" flexShrink={0}>
488
+ <Text color={theme.accent}>{isCompactLayout ? "↑ more" : `↑ ${windowResult.start} more`}</Text>
489
+ </Box>
490
+ )}
491
+
492
+ {mode === "providers" && (windowResult?.showHeaders ?? true) && (
493
+ <Box width="100%" overflow="hidden" flexShrink={0}>
494
+ <Text color={theme.textDim}>
495
+ {" "}
496
+ {clampVisualText("Provider", providerNameWidth)}
497
+ {" "}
498
+ {clampVisualText("Model", modelWidth)}
499
+ {contextWidth > 0 && " " + clampVisualText("Context", contextWidth)}
500
+ {toolsWidth > 0 && " " + clampVisualText("Tool", toolsWidth)}
501
+ {streamWidth > 0 && " " + clampVisualText("Strm", streamWidth)}
502
+ {" "}
503
+ {clampVisualText("Status", statusWidth)}
504
+ </Text>
505
+ </Box>
506
+ )}
507
+
508
+ <Box flexDirection="column" marginTop={0} width="100%" flexShrink={0}>
509
+ {body}
510
+ </Box>
511
+
512
+ {showCurrent && activeRouteIndex >= 0 && (
513
+ <Box height={1} overflow="hidden" flexShrink={0} marginTop={0}>
514
+ <Text color={theme.textDim}>
515
+ Current: <Text color={theme.text}>{providers[activeRouteIndex].displayName} / {providers[activeRouteIndex].currentModel}</Text>
516
+ </Text>
517
+ </Box>
518
+ )}
519
+
520
+ {windowResult?.showBelow && (
521
+ <Box height={1} overflow="hidden" flexShrink={0}>
522
+ <Text color={theme.accent}>{isCompactLayout ? "↓ more" : `↓ ${providers.length - windowResult.end} more`}</Text>
523
+ </Box>
524
+ )}
525
+ </Box>
526
+
527
+ {process.env.CODEXA_DEBUG_LAYOUT === "1" && (
528
+ <Box flexDirection="column" marginTop={1} flexShrink={0}>
529
+ <Text color="red">
530
+ DEBUG layout: rows={layout?.rows} cols={layout?.cols} mode={layout?.mode} headerRows={budget?.headerRows ?? 6} panelRows={availableRows} bottomChromeRows={budget?.bottomChromeBudget.totalRows ?? 4} composerRows={budget?.composerRows ?? 3} providerRows={visibleProviders.length} renderMode={windowResult?.renderMode}
531
+ </Text>
532
+ </Box>
533
+ )}
534
+ </Box>
535
+ );
536
+ }
537
+
538
+ // ─── Subcomponents ───────────────────────────────────────────────────────────
539
+
540
+ function capabilityFlag(value: boolean | null | undefined): string {
541
+ if (value === true) return "Y";
542
+ if (value === false) return "N";
543
+ return "?";
544
+ }
545
+
546
+ function formatCompactStatus(status: string | undefined): string {
547
+ switch (status) {
548
+ case "Active":
549
+ return "Active";
550
+ case "Enabled":
551
+ return "Ready";
552
+ case "Needs config":
553
+ return "Config";
554
+ case "Disabled":
555
+ return "Off";
556
+ case "Unknown":
557
+ default:
558
+ return "?";
559
+ }
560
+ }
561
+
562
+ function formatCompactContext(value: string | number | undefined): string {
563
+ if (value === undefined || value === null) return "?";
564
+ const raw = String(value).trim();
565
+ if (!raw || raw === "Unknown" || raw === "?") return "?";
566
+ const numeric = Number(raw.replace(/,/g, ""));
567
+ if (!Number.isFinite(numeric)) return raw;
568
+ if (numeric >= 1_000_000) {
569
+ return `${(numeric / 1_000_000).toFixed(1)}M`;
570
+ }
571
+ if (numeric >= 1_000) {
572
+ return `${Math.round(numeric / 1_000)}K`;
573
+ }
574
+ return String(numeric);
575
+ }
576
+
577
+ function ProviderRowCompact({
578
+ provider,
579
+ isHighlighted,
580
+ widths,
581
+ }: {
582
+ provider: ProviderConfig;
583
+ isHighlighted: boolean;
584
+ widths: {
585
+ markerWidth: number;
586
+ providerWidth: number;
587
+ modelWidth: number;
588
+ contextWidth: number;
589
+ statusWidth: number;
590
+ };
591
+ }) {
592
+ const theme = useTheme();
593
+ const marker = isHighlighted ? ">" : " ";
594
+ const defaultMark = provider.isDefault ? "*" : " ";
595
+ const activeMark = provider.isActiveRoute ? "@" : " ";
596
+ const statusText = provider.isActiveRoute ? "Active" : provider.statusLabel;
597
+
598
+ const compactStatus = formatCompactStatus(statusText);
599
+ const compactContext = formatCompactContext(provider.contextLengthLabel);
600
+
601
+ const providerColor = isHighlighted ? theme.text : theme.textMuted;
602
+ const modelColor = theme.textDim;
603
+ const statusColor = provider.isActiveRoute
604
+ ? theme.success
605
+ : provider.enabled && !provider.routeUnavailableReason
606
+ ? theme.success
607
+ : theme.warning;
608
+
609
+ return (
610
+ <Box width="100%" overflow="hidden" flexDirection="row" flexShrink={0}>
611
+ <Box width={widths.markerWidth} flexShrink={0}>
612
+ <Text color={isHighlighted ? theme.accent : theme.textDim}>
613
+ {marker} {defaultMark} {activeMark}
614
+ </Text>
615
+ </Box>
616
+ <Box width={widths.providerWidth} flexShrink={0} overflow="hidden">
617
+ <Text color={providerColor} bold={isHighlighted}>
618
+ {clampVisualText(provider.displayName, widths.providerWidth)}
619
+ </Text>
620
+ </Box>
621
+ <Text> </Text>
622
+ <Box width={widths.modelWidth} flexShrink={0} overflow="hidden">
623
+ <Text color={modelColor}>{clampVisualText(provider.currentModel, widths.modelWidth)}</Text>
624
+ </Box>
625
+ <Text> </Text>
626
+ <Box width={widths.contextWidth} flexShrink={0} overflow="hidden">
627
+ <Text color={theme.textDim}>{clampVisualText(compactContext, widths.contextWidth)}</Text>
628
+ </Box>
629
+ <Text> </Text>
630
+ <Box width={widths.statusWidth} flexShrink={0} overflow="hidden">
631
+ <Text color={statusColor}>{clampVisualText(compactStatus, widths.statusWidth)}</Text>
632
+ </Box>
633
+ </Box>
634
+ );
635
+ }
636
+
637
+ function ProviderRow({
638
+ provider,
639
+ isHighlighted,
640
+ widths,
641
+ }: {
642
+ provider: ProviderConfig;
643
+ isHighlighted: boolean;
644
+ widths: {
645
+ providerNameWidth: number;
646
+ modelWidth: number;
647
+ contextWidth: number;
648
+ toolsWidth: number;
649
+ streamWidth: number;
650
+ statusWidth: number;
651
+ };
652
+ }) {
653
+ const theme = useTheme();
654
+ const statusColor = provider.isActiveRoute
655
+ ? theme.success
656
+ : provider.enabled && !provider.routeUnavailableReason
657
+ ? theme.success
658
+ : theme.warning;
659
+ const marker = isHighlighted ? ">" : " ";
660
+ const defaultMark = provider.isDefault ? "*" : " ";
661
+ const activeMark = provider.isActiveRoute ? "@" : " ";
662
+ const statusText = provider.isActiveRoute ? "Active" : provider.statusLabel;
663
+
664
+ return (
665
+ <Box width="100%" overflow="hidden" flexDirection="row" flexShrink={0}>
666
+ <Box width={5} flexShrink={0}>
667
+ <Text color={isHighlighted ? theme.accent : theme.textDim}>{marker} {defaultMark} {activeMark}</Text>
668
+ </Box>
669
+ <Box width={widths.providerNameWidth} flexShrink={0} overflow="hidden">
670
+ <Text color={isHighlighted ? theme.text : theme.textMuted} bold={isHighlighted}>
671
+ {clampVisualText(provider.displayName, widths.providerNameWidth)}
672
+ </Text>
673
+ </Box>
674
+ <Text> </Text>
675
+ <Box width={widths.modelWidth} flexShrink={0} overflow="hidden">
676
+ <Text color={theme.textMuted}>{clampVisualText(provider.currentModel, widths.modelWidth)}</Text>
677
+ </Box>
678
+ {widths.contextWidth > 0 && (
679
+ <>
680
+ <Text> </Text>
681
+ <Box width={widths.contextWidth} flexShrink={0} overflow="hidden">
682
+ <Text color={theme.textMuted}>{clampVisualText(provider.contextLengthLabel ?? "Unknown", widths.contextWidth)}</Text>
683
+ </Box>
684
+ </>
685
+ )}
686
+ {widths.toolsWidth > 0 && (
687
+ <>
688
+ <Text> </Text>
689
+ <Box width={widths.toolsWidth} flexShrink={0} overflow="hidden">
690
+ <Text color={theme.textMuted}>{clampVisualText(capabilityFlag(provider.capabilityProfile?.supportsToolCalls), widths.toolsWidth)}</Text>
691
+ </Box>
692
+ </>
693
+ )}
694
+ {widths.streamWidth > 0 && (
695
+ <>
696
+ <Text> </Text>
697
+ <Box width={widths.streamWidth} flexShrink={0} overflow="hidden">
698
+ <Text color={theme.textMuted}>{clampVisualText(capabilityFlag(provider.capabilityProfile?.supportsStreaming), widths.streamWidth)}</Text>
699
+ </Box>
700
+ </>
701
+ )}
702
+ <Text> </Text>
703
+ <Box width={widths.statusWidth} flexShrink={0} overflow="hidden">
704
+ <Text color={statusColor}>{clampVisualText(statusText, widths.statusWidth)}</Text>
705
+ </Box>
706
+ </Box>
707
+ );
708
+ }
709
+
710
+ function ActionRow({
711
+ label,
712
+ disabledReason,
713
+ isHighlighted,
714
+ width,
715
+ }: {
716
+ label: string;
717
+ disabledReason?: string | null;
718
+ isHighlighted: boolean;
719
+ width: number;
720
+ }) {
721
+ const theme = useTheme();
722
+ const text = disabledReason ? `${label} unavailable` : label;
723
+ return (
724
+ <Box width="100%" overflow="hidden">
725
+ <Box width={2} flexShrink={0}>
726
+ <Text color={isHighlighted ? theme.accent : theme.textDim}>{isHighlighted ? ">" : " "}</Text>
727
+ </Box>
728
+ <Box width={Math.max(10, width - 2)} flexShrink={0} overflow="hidden">
729
+ <Text color={disabledReason ? theme.textDim : isHighlighted ? theme.text : theme.textMuted} bold={isHighlighted && !disabledReason}>
730
+ {clampVisualText(text, Math.max(10, width - 2))}
731
+ </Text>
732
+ </Box>
733
+ </Box>
734
+ );
735
+ }