@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.
Files changed (226) hide show
  1. package/README.md +320 -0
  2. package/bin/codexa.js +445 -0
  3. package/package.json +45 -0
  4. package/scripts/audit-codexa-capabilities.mjs +466 -0
  5. package/scripts/smoke-terminal-bench.mjs +35 -0
  6. package/src/app.tsx +4561 -0
  7. package/src/appRenderStability.test.ts +131 -0
  8. package/src/commands/handler.test.ts +643 -0
  9. package/src/commands/handler.ts +875 -0
  10. package/src/config/launchArgs.test.ts +158 -0
  11. package/src/config/launchArgs.ts +186 -0
  12. package/src/config/layeredConfig.test.ts +143 -0
  13. package/src/config/layeredConfig.ts +836 -0
  14. package/src/config/persistence.test.ts +110 -0
  15. package/src/config/persistence.ts +311 -0
  16. package/src/config/runtimeConfig.test.ts +218 -0
  17. package/src/config/runtimeConfig.ts +554 -0
  18. package/src/config/settings.test.ts +155 -0
  19. package/src/config/settings.ts +401 -0
  20. package/src/config/toml-serialize.ts +98 -0
  21. package/src/config/trustStore.test.ts +29 -0
  22. package/src/config/trustStore.ts +68 -0
  23. package/src/core/attachments.test.ts +155 -0
  24. package/src/core/attachments.ts +71 -0
  25. package/src/core/auth/codexAuth.test.ts +68 -0
  26. package/src/core/auth/codexAuth.ts +359 -0
  27. package/src/core/cleanupFastFail.test.ts +76 -0
  28. package/src/core/cleanupFastFail.ts +67 -0
  29. package/src/core/clipboard.ts +24 -0
  30. package/src/core/codex.ts +124 -0
  31. package/src/core/codexExecArgs.test.ts +195 -0
  32. package/src/core/codexExecArgs.ts +152 -0
  33. package/src/core/codexLaunch.test.ts +205 -0
  34. package/src/core/codexLaunch.ts +162 -0
  35. package/src/core/codexPrompt.test.ts +252 -0
  36. package/src/core/codexPrompt.ts +428 -0
  37. package/src/core/executables/claudeExecutable.ts +63 -0
  38. package/src/core/executables/codexExecutable.test.ts +212 -0
  39. package/src/core/executables/codexExecutable.ts +159 -0
  40. package/src/core/executables/executableResolver.test.ts +129 -0
  41. package/src/core/executables/executableResolver.ts +138 -0
  42. package/src/core/executables/geminiExecutable.test.ts +116 -0
  43. package/src/core/executables/geminiExecutable.ts +78 -0
  44. package/src/core/executables/pathSanityScan.test.ts +47 -0
  45. package/src/core/githubDiagnostics.test.ts +92 -0
  46. package/src/core/githubDiagnostics.ts +222 -0
  47. package/src/core/hollowResponseFormat.test.ts +58 -0
  48. package/src/core/hollowResponseFormat.ts +39 -0
  49. package/src/core/inputDebug.ts +51 -0
  50. package/src/core/launchContext.test.ts +157 -0
  51. package/src/core/launchContext.ts +266 -0
  52. package/src/core/models/codexCapabilities.test.ts +45 -0
  53. package/src/core/models/codexCapabilities.ts +95 -0
  54. package/src/core/models/codexModelCapabilities.test.ts +246 -0
  55. package/src/core/models/codexModelCapabilities.ts +571 -0
  56. package/src/core/models/modelSpecs.test.ts +283 -0
  57. package/src/core/models/modelSpecs.ts +300 -0
  58. package/src/core/perf/profiler.ts +125 -0
  59. package/src/core/perf/renderDebug.test.ts +230 -0
  60. package/src/core/perf/renderDebug.ts +373 -0
  61. package/src/core/planStorage.test.ts +143 -0
  62. package/src/core/planStorage.ts +141 -0
  63. package/src/core/process/CommandRunner.test.ts +105 -0
  64. package/src/core/process/CommandRunner.ts +269 -0
  65. package/src/core/process/processValidation.ts +101 -0
  66. package/src/core/projectInstructions.test.ts +50 -0
  67. package/src/core/projectInstructions.ts +54 -0
  68. package/src/core/providerLauncher/launcher.test.ts +238 -0
  69. package/src/core/providerLauncher/launcher.ts +203 -0
  70. package/src/core/providerLauncher/registry.test.ts +324 -0
  71. package/src/core/providerLauncher/registry.ts +253 -0
  72. package/src/core/providerLauncher/types.ts +84 -0
  73. package/src/core/providerLauncher/workspaceConfig.test.ts +638 -0
  74. package/src/core/providerLauncher/workspaceConfig.ts +407 -0
  75. package/src/core/providerRuntime/anthropic.test.ts +1120 -0
  76. package/src/core/providerRuntime/anthropic.ts +576 -0
  77. package/src/core/providerRuntime/capabilityProfile.test.ts +311 -0
  78. package/src/core/providerRuntime/capabilityProfile.ts +288 -0
  79. package/src/core/providerRuntime/claudeCodeDiscovery.ts +446 -0
  80. package/src/core/providerRuntime/contextMetadata.test.ts +468 -0
  81. package/src/core/providerRuntime/contextMetadata.ts +409 -0
  82. package/src/core/providerRuntime/gemini.test.ts +437 -0
  83. package/src/core/providerRuntime/gemini.ts +784 -0
  84. package/src/core/providerRuntime/lmstudio.test.ts +168 -0
  85. package/src/core/providerRuntime/lmstudio.ts +118 -0
  86. package/src/core/providerRuntime/local.test.ts +787 -0
  87. package/src/core/providerRuntime/local.ts +754 -0
  88. package/src/core/providerRuntime/models.ts +150 -0
  89. package/src/core/providerRuntime/reasoning.ts +17 -0
  90. package/src/core/providerRuntime/registry.test.ts +233 -0
  91. package/src/core/providerRuntime/registry.ts +203 -0
  92. package/src/core/providerRuntime/types.ts +103 -0
  93. package/src/core/providers/codexJsonStream.test.ts +148 -0
  94. package/src/core/providers/codexJsonStream.ts +305 -0
  95. package/src/core/providers/codexSubprocess.test.ts +68 -0
  96. package/src/core/providers/codexSubprocess.ts +372 -0
  97. package/src/core/providers/codexTranscript.test.ts +284 -0
  98. package/src/core/providers/codexTranscript.ts +695 -0
  99. package/src/core/providers/openaiNative.ts +13 -0
  100. package/src/core/providers/registry.ts +21 -0
  101. package/src/core/providers/types.ts +59 -0
  102. package/src/core/terminal/terminalCapabilities.test.ts +93 -0
  103. package/src/core/terminal/terminalCapabilities.ts +100 -0
  104. package/src/core/terminal/terminalControl.test.ts +75 -0
  105. package/src/core/terminal/terminalControl.ts +147 -0
  106. package/src/core/terminal/terminalSanitize.test.ts +22 -0
  107. package/src/core/terminal/terminalSanitize.ts +147 -0
  108. package/src/core/terminal/terminalSelection.test.ts +42 -0
  109. package/src/core/terminal/terminalSelection.ts +66 -0
  110. package/src/core/terminal/terminalTitle.test.ts +328 -0
  111. package/src/core/terminal/terminalTitle.ts +483 -0
  112. package/src/core/workspaceActivity.test.ts +163 -0
  113. package/src/core/workspaceActivity.ts +380 -0
  114. package/src/core/workspaceGuard.test.ts +151 -0
  115. package/src/core/workspaceGuard.ts +288 -0
  116. package/src/core/workspaceRoot.test.ts +23 -0
  117. package/src/core/workspaceRoot.ts +47 -0
  118. package/src/exec.test.ts +13 -0
  119. package/src/exec.ts +72 -0
  120. package/src/headless/execArgs.test.ts +147 -0
  121. package/src/headless/execArgs.ts +294 -0
  122. package/src/headless/execRunner.test.ts +434 -0
  123. package/src/headless/execRunner.ts +304 -0
  124. package/src/index.test.tsx +618 -0
  125. package/src/index.tsx +296 -0
  126. package/src/session/appSession.test.ts +897 -0
  127. package/src/session/appSession.ts +761 -0
  128. package/src/session/chatLifecycle.test.ts +64 -0
  129. package/src/session/chatLifecycle.ts +951 -0
  130. package/src/session/liveRenderScheduler.test.ts +201 -0
  131. package/src/session/liveRenderScheduler.ts +214 -0
  132. package/src/session/planFlow.test.ts +103 -0
  133. package/src/session/planFlow.ts +149 -0
  134. package/src/session/planTranscript.test.ts +65 -0
  135. package/src/session/planTranscript.ts +15 -0
  136. package/src/session/promptRunSchedule.test.ts +36 -0
  137. package/src/session/promptRunSchedule.ts +26 -0
  138. package/src/session/types.ts +228 -0
  139. package/src/test/runtimeTestUtils.ts +14 -0
  140. package/src/types/react-dom.d.ts +3 -0
  141. package/src/ui/ActionRequiredBlock.tsx +38 -0
  142. package/src/ui/ActivityBars.tsx +68 -0
  143. package/src/ui/ActivityIndicator.test.tsx +58 -0
  144. package/src/ui/ActivityIndicator.tsx +58 -0
  145. package/src/ui/AgentBlock.test.ts +6 -0
  146. package/src/ui/AgentBlock.tsx +130 -0
  147. package/src/ui/AnimatedStatusText.test.ts +16 -0
  148. package/src/ui/AnimatedStatusText.tsx +69 -0
  149. package/src/ui/AppShell.test.tsx +1739 -0
  150. package/src/ui/AppShell.tsx +698 -0
  151. package/src/ui/AttachmentImportPanel.test.tsx +204 -0
  152. package/src/ui/AttachmentImportPanel.tsx +98 -0
  153. package/src/ui/AuthPanel.tsx +113 -0
  154. package/src/ui/BackendPicker.tsx +28 -0
  155. package/src/ui/BottomComposer.test.ts +674 -0
  156. package/src/ui/BottomComposer.tsx +1028 -0
  157. package/src/ui/CodexLogo.tsx +55 -0
  158. package/src/ui/DashCard.tsx +82 -0
  159. package/src/ui/Markdown.test.ts +157 -0
  160. package/src/ui/Markdown.tsx +310 -0
  161. package/src/ui/ModePicker.tsx +27 -0
  162. package/src/ui/ModelPicker.tsx +31 -0
  163. package/src/ui/ModelPickerProviderScope.test.tsx +411 -0
  164. package/src/ui/ModelPickerScreen.test.tsx +99 -0
  165. package/src/ui/ModelPickerScreen.tsx +416 -0
  166. package/src/ui/ModelPickerState.test.tsx +151 -0
  167. package/src/ui/ModelReasoningPicker.test.tsx +447 -0
  168. package/src/ui/ModelReasoningPicker.tsx +458 -0
  169. package/src/ui/Panel.tsx +51 -0
  170. package/src/ui/PermissionsPanel.tsx +78 -0
  171. package/src/ui/PlanActionPicker.tsx +119 -0
  172. package/src/ui/PlanReviewPanel.test.tsx +267 -0
  173. package/src/ui/PlanReviewPanel.tsx +212 -0
  174. package/src/ui/PromptCardBorder.test.tsx +161 -0
  175. package/src/ui/ProviderPicker.test.tsx +289 -0
  176. package/src/ui/ProviderPicker.tsx +321 -0
  177. package/src/ui/ProviderShortcut.test.tsx +143 -0
  178. package/src/ui/ReasoningPicker.tsx +46 -0
  179. package/src/ui/RunFooter.tsx +65 -0
  180. package/src/ui/SelectionPanel.tsx +67 -0
  181. package/src/ui/SettingsPanel.test.tsx +233 -0
  182. package/src/ui/SettingsPanel.tsx +156 -0
  183. package/src/ui/Spinner.tsx +25 -0
  184. package/src/ui/StaticIntroItem.tsx +54 -0
  185. package/src/ui/StaticTranscriptItem.tsx +56 -0
  186. package/src/ui/TextEntryPanel.tsx +139 -0
  187. package/src/ui/ThemePicker.tsx +31 -0
  188. package/src/ui/ThinkingBlock.tsx +100 -0
  189. package/src/ui/Timeline.test.ts +2067 -0
  190. package/src/ui/Timeline.tsx +1472 -0
  191. package/src/ui/TimelineNavigation.test.tsx +201 -0
  192. package/src/ui/TopHeader.test.tsx +239 -0
  193. package/src/ui/TopHeader.tsx +257 -0
  194. package/src/ui/TurnGroup.test.tsx +365 -0
  195. package/src/ui/TurnGroup.tsx +657 -0
  196. package/src/ui/busyStatusAnimation.test.ts +30 -0
  197. package/src/ui/busyStatusAnimation.ts +11 -0
  198. package/src/ui/commandNormalize.test.ts +142 -0
  199. package/src/ui/commandNormalize.ts +66 -0
  200. package/src/ui/diffRenderer.test.ts +102 -0
  201. package/src/ui/diffRenderer.ts +116 -0
  202. package/src/ui/focus.ts +61 -0
  203. package/src/ui/focusFlow.test.tsx +1098 -0
  204. package/src/ui/inputBuffer.test.ts +151 -0
  205. package/src/ui/inputBuffer.ts +203 -0
  206. package/src/ui/layout.test.ts +145 -0
  207. package/src/ui/layout.ts +287 -0
  208. package/src/ui/modeDisplay.test.ts +42 -0
  209. package/src/ui/modeDisplay.ts +52 -0
  210. package/src/ui/outputPipeline.ts +64 -0
  211. package/src/ui/progressEntries.ts +156 -0
  212. package/src/ui/runActivityView.test.ts +89 -0
  213. package/src/ui/runActivityView.ts +37 -0
  214. package/src/ui/runLifecycleView.test.tsx +237 -0
  215. package/src/ui/slashCommands.ts +41 -0
  216. package/src/ui/statusRenderIsolation.test.tsx +654 -0
  217. package/src/ui/terminalAnswerFormat.test.ts +19 -0
  218. package/src/ui/terminalAnswerFormat.ts +128 -0
  219. package/src/ui/textLayout.test.ts +18 -0
  220. package/src/ui/textLayout.ts +338 -0
  221. package/src/ui/theme.tsx +395 -0
  222. package/src/ui/themeFlow.test.ts +53 -0
  223. package/src/ui/themeFlow.ts +41 -0
  224. package/src/ui/timelineMeasure.ts +3088 -0
  225. package/src/ui/timelineMeasureCache.test.ts +986 -0
  226. package/src/ui/useThrottledValue.ts +31 -0
@@ -0,0 +1,458 @@
1
+ import { useState, useCallback, useEffect, useMemo, useRef } 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 { useTheme } from "./theme.js";
12
+
13
+ // ─── Types & helpers ─────────────────────────────────────────────────────────
14
+
15
+ type ModelPickerCloseReason = "escape" | "empty-selection";
16
+
17
+ interface ModelReasoningPickerProps {
18
+ models: readonly CodexModelCapability[];
19
+ currentModel: string;
20
+ currentReasoning: string;
21
+ isLoading?: boolean;
22
+ onSelect: (model: string, reasoning: string) => void;
23
+ onCancel: (reason?: ModelPickerCloseReason) => void;
24
+ }
25
+
26
+ function getModelReasoningLevels(model: CodexModelCapability): readonly ReasoningEffortCapability[] {
27
+ return model.supportedReasoningLevels ?? [];
28
+ }
29
+
30
+ function getInitialReasoning(model: CodexModelCapability, currentReasoning: string): string {
31
+ return normalizeReasoningForModelCapabilities(
32
+ model.model,
33
+ currentReasoning,
34
+ {
35
+ status: "ready",
36
+ source: model.source,
37
+ models: [model],
38
+ discoveredAt: Date.now(),
39
+ executable: null,
40
+ error: null,
41
+ },
42
+ );
43
+ }
44
+
45
+ function getInitialCursor(models: readonly CodexModelCapability[], currentModel: string): number {
46
+ return Math.max(0, models.findIndex((model) => model.model === currentModel || model.id === currentModel));
47
+ }
48
+
49
+ function buildPendingReasoning(
50
+ models: readonly CodexModelCapability[],
51
+ currentReasoning: string,
52
+ ): Record<string, string> {
53
+ const next: Record<string, string> = {};
54
+ for (const model of models) {
55
+ next[model.model] = getInitialReasoning(model, currentReasoning);
56
+ }
57
+ return next;
58
+ }
59
+
60
+ function describeInputKey(
61
+ input: string,
62
+ key: {
63
+ escape?: boolean;
64
+ return?: boolean;
65
+ upArrow?: boolean;
66
+ downArrow?: boolean;
67
+ leftArrow?: boolean;
68
+ rightArrow?: boolean;
69
+ ctrl?: boolean;
70
+ meta?: boolean;
71
+ },
72
+ ) {
73
+ return {
74
+ input,
75
+ escape: Boolean(key.escape),
76
+ return: Boolean(key.return),
77
+ upArrow: Boolean(key.upArrow),
78
+ downArrow: Boolean(key.downArrow),
79
+ leftArrow: Boolean(key.leftArrow),
80
+ rightArrow: Boolean(key.rightArrow),
81
+ ctrl: Boolean(key.ctrl),
82
+ meta: Boolean(key.meta),
83
+ };
84
+ }
85
+
86
+ // ─── Component ────────────────────────────────────────────────────────────────
87
+
88
+ export function ModelReasoningPicker({
89
+ models,
90
+ currentModel,
91
+ currentReasoning,
92
+ isLoading = false,
93
+ onSelect,
94
+ onCancel,
95
+ }: ModelReasoningPickerProps) {
96
+ const theme = useTheme();
97
+ const { isFocused } = useFocus({ id: FOCUS_IDS.modelPicker, autoFocus: true });
98
+ const visibleModels = models;
99
+ const initializedModelsRef = useRef(false);
100
+
101
+ const [cursor, setCursor] = useState(() =>
102
+ getInitialCursor(visibleModels, currentModel),
103
+ );
104
+
105
+ const [pendingReasoning, setPendingReasoning] = useState<Record<string, string>>(() =>
106
+ buildPendingReasoning(visibleModels, currentReasoning)
107
+ );
108
+
109
+ useEffect(() => {
110
+ traceInputDebug("model_picker_mounted", {
111
+ focusTarget: FOCUS_IDS.modelPicker,
112
+ modelCount: visibleModels.length,
113
+ isLoading,
114
+ });
115
+ return () => {
116
+ traceInputDebug("model_picker_unmounted", {
117
+ focusTarget: FOCUS_IDS.modelPicker,
118
+ });
119
+ };
120
+ }, []);
121
+
122
+ useEffect(() => {
123
+ traceInputDebug("model_picker_focus", {
124
+ isFocused,
125
+ focusTarget: FOCUS_IDS.modelPicker,
126
+ modelCount: visibleModels.length,
127
+ isLoading,
128
+ });
129
+ }, [isFocused, isLoading, visibleModels.length]);
130
+
131
+ useEffect(() => {
132
+ traceInputDebug("model_picker_models_state", {
133
+ isLoading,
134
+ modelCount: visibleModels.length,
135
+ currentModel,
136
+ });
137
+
138
+ if (visibleModels.length === 0) {
139
+ initializedModelsRef.current = false;
140
+ setCursor(0);
141
+ setPendingReasoning({});
142
+ return;
143
+ }
144
+
145
+ setCursor((currentCursor) => {
146
+ const maxCursor = Math.max(0, visibleModels.length - 1);
147
+ if (!initializedModelsRef.current) {
148
+ initializedModelsRef.current = true;
149
+ return Math.min(getInitialCursor(visibleModels, currentModel), maxCursor);
150
+ }
151
+ return Math.min(Math.max(0, currentCursor), maxCursor);
152
+ });
153
+
154
+ setPendingReasoning((prev) => {
155
+ const next: Record<string, string> = {};
156
+ let changed = Object.keys(prev).length !== visibleModels.length;
157
+
158
+ for (const model of visibleModels) {
159
+ const value = prev[model.model] ?? getInitialReasoning(model, currentReasoning);
160
+ next[model.model] = value;
161
+ if (prev[model.model] !== value) {
162
+ changed = true;
163
+ }
164
+ }
165
+
166
+ return changed ? next : prev;
167
+ });
168
+ }, [currentModel, currentReasoning, isLoading, visibleModels]);
169
+
170
+ const highlightedModel = visibleModels[Math.min(cursor, Math.max(0, visibleModels.length - 1))];
171
+
172
+ const moveReasoning = useCallback(
173
+ (direction: -1 | 1) => {
174
+ const model = visibleModels[cursor];
175
+ if (!model) return;
176
+
177
+ const available = getModelReasoningLevels(model);
178
+ if (available.length <= 1) return;
179
+
180
+ setPendingReasoning((prev) => {
181
+ const currentValue = prev[model.model] ?? getInitialReasoning(model, currentReasoning);
182
+ const currentIdx = Math.max(0, available.findIndex((level) => level.id === currentValue));
183
+ const nextIdx = Math.max(0, Math.min(available.length - 1, currentIdx + direction));
184
+ if (nextIdx === currentIdx) return prev;
185
+ return { ...prev, [model.model]: available[nextIdx]!.id };
186
+ });
187
+ },
188
+ [currentReasoning, cursor, visibleModels],
189
+ );
190
+
191
+ useInput(
192
+ (input, key) => {
193
+ traceInputDebug("model_picker_input", {
194
+ handler: "ModelReasoningPicker.useInput",
195
+ key: describeInputKey(input, key),
196
+ isFocused,
197
+ isLoading,
198
+ modelCount: visibleModels.length,
199
+ cursor,
200
+ });
201
+
202
+ if (key.escape) {
203
+ traceInputDebug("model_picker_close_request", {
204
+ reason: "escape",
205
+ handler: "ModelReasoningPicker.useInput",
206
+ modelCount: visibleModels.length,
207
+ });
208
+ onCancel("escape");
209
+ return;
210
+ }
211
+ if (key.return) {
212
+ const model = visibleModels[cursor];
213
+ if (!model) {
214
+ traceInputDebug("model_picker_close_request", {
215
+ reason: "empty-selection",
216
+ handler: "ModelReasoningPicker.useInput",
217
+ modelCount: visibleModels.length,
218
+ });
219
+ onCancel("empty-selection");
220
+ return;
221
+ }
222
+ const reasoning = pendingReasoning[model.model] ?? getInitialReasoning(model, currentReasoning);
223
+ traceInputDebug("model_selection_start", {
224
+ handler: "ModelReasoningPicker.useInput",
225
+ model: model.model,
226
+ reasoning,
227
+ });
228
+ onSelect(model.model, reasoning);
229
+ return;
230
+ }
231
+ if (key.upArrow) {
232
+ setCursor((c) => Math.max(0, c - 1));
233
+ return;
234
+ }
235
+ if (key.downArrow) {
236
+ setCursor((c) => Math.min(visibleModels.length - 1, c + 1));
237
+ return;
238
+ }
239
+ if (key.leftArrow) {
240
+ moveReasoning(-1);
241
+ return;
242
+ }
243
+ if (key.rightArrow) {
244
+ moveReasoning(1);
245
+ }
246
+ },
247
+ { isActive: isFocused },
248
+ );
249
+
250
+ const rows = useMemo(
251
+ () =>
252
+ visibleModels.map((model) => {
253
+ const available = getModelReasoningLevels(model);
254
+ return {
255
+ model,
256
+ available,
257
+ interactive: available.length > 1,
258
+ };
259
+ }),
260
+ [visibleModels],
261
+ );
262
+
263
+ if (visibleModels.length === 0) {
264
+ return <LoadingPickerView theme={theme} isLoading={isLoading} />;
265
+ }
266
+
267
+ return (
268
+ <InteractivePickerView
269
+ rows={rows}
270
+ cursor={cursor}
271
+ currentModel={currentModel}
272
+ currentReasoning={currentReasoning}
273
+ pendingReasoning={pendingReasoning}
274
+ highlightedModel={highlightedModel}
275
+ theme={theme}
276
+ />
277
+ );
278
+ }
279
+
280
+ // ─── Subcomponents ───────────────────────────────────────────────────────────
281
+
282
+ function LoadingPickerView({
283
+ theme,
284
+ isLoading,
285
+ }: {
286
+ theme: ReturnType<typeof useTheme>;
287
+ isLoading: boolean;
288
+ }) {
289
+ return (
290
+ <Box flexDirection="column" width="100%">
291
+ <Box
292
+ borderStyle="round"
293
+ borderColor={theme.BORDER_SUBTLE}
294
+ paddingX={2}
295
+ paddingY={0}
296
+ width="100%"
297
+ >
298
+ <Box flexDirection="column" width="100%">
299
+ <Box>
300
+ <Text color={theme.ACCENT} bold>Select model </Text>
301
+ <Text color={theme.MUTED}>Esc cancel</Text>
302
+ </Box>
303
+ <Box marginTop={0}>
304
+ <Text color={theme.DIM}>
305
+ {isLoading
306
+ ? "Discovering models from the Codex runtime…"
307
+ : "No models available yet."}
308
+ </Text>
309
+ </Box>
310
+ </Box>
311
+ </Box>
312
+ </Box>
313
+ );
314
+ }
315
+
316
+ interface InteractivePickerViewProps {
317
+ rows: Array<{
318
+ model: CodexModelCapability;
319
+ available: readonly ReasoningEffortCapability[];
320
+ interactive: boolean;
321
+ }>;
322
+ cursor: number;
323
+ currentModel: string;
324
+ currentReasoning: string;
325
+ pendingReasoning: Record<string, string>;
326
+ highlightedModel: CodexModelCapability | undefined;
327
+ theme: ReturnType<typeof useTheme>;
328
+ }
329
+
330
+ function InteractivePickerView({
331
+ rows,
332
+ cursor,
333
+ currentModel,
334
+ currentReasoning,
335
+ pendingReasoning,
336
+ highlightedModel,
337
+ theme,
338
+ }: InteractivePickerViewProps) {
339
+ const subtitleParts: string[] = ["↑↓ model"];
340
+ if (highlightedModel && getModelReasoningLevels(highlightedModel).length > 1) {
341
+ subtitleParts.push("←→ reasoning");
342
+ }
343
+ subtitleParts.push("Enter select", "Esc cancel");
344
+ const subtitle = subtitleParts.join(" · ");
345
+
346
+ const highlightedPending = highlightedModel
347
+ ? pendingReasoning[highlightedModel.model] ?? getInitialReasoning(highlightedModel, currentReasoning)
348
+ : currentReasoning;
349
+ const reasoningHint = highlightedModel?.supportedReasoningLevels
350
+ ? `Reasoning: ${formatReasoningLabel(highlightedPending)}`
351
+ : "Reasoning metadata unavailable";
352
+
353
+ return (
354
+ <Box
355
+ borderStyle="round"
356
+ borderColor={theme.BORDER_ACTIVE}
357
+ paddingX={2}
358
+ paddingY={0}
359
+ width="100%"
360
+ flexDirection="column"
361
+ >
362
+ <Box>
363
+ <Text color={theme.ACCENT} bold>Select model </Text>
364
+ <Text color={theme.MUTED}>{subtitle}</Text>
365
+ </Box>
366
+ <Box marginTop={0}>
367
+ <Text color={theme.DIM}>{reasoningHint}</Text>
368
+ </Box>
369
+
370
+ <Box
371
+ marginTop={0}
372
+ width="100%"
373
+ flexDirection="column"
374
+ >
375
+ {rows.map((row, idx) => {
376
+ const isHighlighted = idx === cursor;
377
+ const isCommitted = row.model.model === currentModel || row.model.id === currentModel;
378
+ const pending = pendingReasoning[row.model.model] ?? getInitialReasoning(row.model, currentReasoning);
379
+
380
+ return (
381
+ <ModelRow
382
+ key={row.model.id}
383
+ model={row.model}
384
+ availableLevels={row.available}
385
+ interactive={row.interactive}
386
+ isHighlighted={isHighlighted}
387
+ isCommitted={isCommitted}
388
+ selectedReasoning={pending}
389
+ theme={theme}
390
+ />
391
+ );
392
+ })}
393
+ </Box>
394
+ </Box>
395
+ );
396
+ }
397
+
398
+ interface ModelRowProps {
399
+ model: CodexModelCapability;
400
+ availableLevels: readonly ReasoningEffortCapability[];
401
+ interactive: boolean;
402
+ isHighlighted: boolean;
403
+ isCommitted: boolean;
404
+ selectedReasoning: string;
405
+ theme: ReturnType<typeof useTheme>;
406
+ }
407
+
408
+ function ModelRow({
409
+ model,
410
+ availableLevels,
411
+ interactive,
412
+ isHighlighted,
413
+ isCommitted,
414
+ selectedReasoning,
415
+ theme,
416
+ }: ModelRowProps) {
417
+ const cursorGlyph = isHighlighted ? "▸ " : " ";
418
+ const nameColor = isHighlighted ? theme.TEXT : theme.MUTED;
419
+ const commitMark = isCommitted ? " ✓" : "";
420
+ const selectedIndex = availableLevels.findIndex((level) => level.id === selectedReasoning);
421
+ const name = model.label === model.model ? model.model : `${model.label} (${model.model})`;
422
+
423
+ const bars = availableLevels.map((level, i) => {
424
+ const isActive = i === selectedIndex;
425
+ const color = !interactive
426
+ ? theme.DIM
427
+ : isActive
428
+ ? isHighlighted ? theme.ACCENT : theme.TEXT
429
+ : theme.DIM;
430
+
431
+ return (
432
+ <Text key={level.id} color={color} bold={isActive && isHighlighted && interactive}>
433
+
434
+ </Text>
435
+ );
436
+ });
437
+
438
+ return (
439
+ <Box flexDirection="row" width="100%">
440
+ <Box width={3} flexShrink={0}>
441
+ <Text color={isHighlighted ? theme.ACCENT : theme.DIM}>{cursorGlyph}</Text>
442
+ </Box>
443
+ <Box flexGrow={1} flexDirection="row" paddingRight={1}>
444
+ <Box flexShrink={1}>
445
+ <Text color={nameColor} bold={isHighlighted} wrap="truncate-end">
446
+ {name}
447
+ </Text>
448
+ </Box>
449
+ <Box flexShrink={0} paddingLeft={1}>
450
+ <Text color={theme.DIM}>{commitMark}</Text>
451
+ </Box>
452
+ </Box>
453
+ <Box flexDirection="row" gap={1} flexShrink={0}>
454
+ {isHighlighted && bars}
455
+ </Box>
456
+ </Box>
457
+ );
458
+ }
@@ -0,0 +1,51 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import { useTheme } from "./theme.js";
4
+
5
+ interface PanelProps {
6
+ cols: number;
7
+ title: string;
8
+ rightTitle?: string;
9
+ borderColor?: string;
10
+ titleColor?: string;
11
+ children: React.ReactNode;
12
+ }
13
+
14
+ export function Panel({ cols, title, rightTitle, borderColor, titleColor, children }: PanelProps) {
15
+ const theme = useTheme();
16
+ const cBorder = borderColor || theme.BORDER_ACTIVE;
17
+ const cTitle = titleColor || theme.TEXT;
18
+
19
+ const leftLabel = ` ${title} `;
20
+ const rightLabel = rightTitle ? ` ${rightTitle} ` : "";
21
+
22
+ // ╭─ TITLE ─── RIGHTTITLE ╮
23
+ // Calculate remaining dashes
24
+ // total length = 2 (╭─) + leftLabel + dashes + rightLabel + 1 (╮) = cols
25
+ // dashes = cols - 3 - leftLabel.length - rightLabel.length
26
+ const maxDashes = cols - 3 - leftLabel.length - rightLabel.length;
27
+ const dashCount = Math.max(0, maxDashes);
28
+
29
+ return (
30
+ <Box flexDirection="column" width={cols} overflow="hidden">
31
+ <Text color={cBorder}>
32
+ {"╭─"}
33
+ <Text color={cTitle}>{leftLabel}</Text>
34
+ {"─".repeat(dashCount)}
35
+ {rightTitle && <Text color={theme.DIM}>{rightLabel}</Text>}
36
+ {"╮"}
37
+ </Text>
38
+ <Box
39
+ flexDirection="column"
40
+ borderStyle="round"
41
+ borderTop={false}
42
+ borderColor={cBorder}
43
+ width={cols}
44
+ paddingX={1}
45
+ paddingY={0}
46
+ >
47
+ {children}
48
+ </Box>
49
+ </Box>
50
+ );
51
+ }
@@ -0,0 +1,78 @@
1
+ import type { ResolvedRuntimeConfig, RuntimeConfig } from "../config/runtimeConfig.js";
2
+ import {
3
+ formatApprovalPolicyLabel,
4
+ formatNetworkAccessLabel,
5
+ formatSandboxModeLabel,
6
+ } from "../config/runtimeConfig.js";
7
+ import { FOCUS_IDS } from "./focus.js";
8
+ import { SelectionPanel } from "./SelectionPanel.js";
9
+
10
+ export type PermissionsPanelAction =
11
+ | "approval-policy"
12
+ | "sandbox"
13
+ | "network"
14
+ | "writable-roots-summary"
15
+ | "writable-roots-add"
16
+ | "writable-roots-remove"
17
+ | "writable-roots-clear";
18
+
19
+ interface PermissionsPanelProps {
20
+ runtime: RuntimeConfig;
21
+ resolvedRuntime: ResolvedRuntimeConfig;
22
+ onSelect: (action: PermissionsPanelAction) => void;
23
+ onCancel: () => void;
24
+ }
25
+
26
+ function formatRootsSummary(count: number): string {
27
+ return count === 1 ? "1 configured" : `${count} configured`;
28
+ }
29
+
30
+ export function PermissionsPanel({
31
+ runtime,
32
+ resolvedRuntime,
33
+ onSelect,
34
+ onCancel,
35
+ }: PermissionsPanelProps) {
36
+ const items = [
37
+ {
38
+ label: `Approval policy ${formatApprovalPolicyLabel(resolvedRuntime.policy.approvalPolicy)} (configured: ${formatApprovalPolicyLabel(runtime.policy.approvalPolicy)})`,
39
+ value: "approval-policy",
40
+ },
41
+ {
42
+ label: `Sandbox mode ${formatSandboxModeLabel(resolvedRuntime.policy.sandboxMode)} (configured: ${formatSandboxModeLabel(runtime.policy.sandboxMode)})`,
43
+ value: "sandbox",
44
+ },
45
+ {
46
+ label: `Network access ${formatNetworkAccessLabel(resolvedRuntime.policy.networkAccess)} (configured: ${formatNetworkAccessLabel(runtime.policy.networkAccess)})`,
47
+ value: "network",
48
+ },
49
+ {
50
+ label: `Writable roots ${formatRootsSummary(runtime.policy.writableRoots.length)}`,
51
+ value: "writable-roots-summary",
52
+ },
53
+ {
54
+ label: "Add writable root",
55
+ value: "writable-roots-add",
56
+ },
57
+ {
58
+ label: "Remove writable root",
59
+ value: "writable-roots-remove",
60
+ },
61
+ {
62
+ label: "Clear writable roots",
63
+ value: "writable-roots-clear",
64
+ },
65
+ ] satisfies Array<{ label: string; value: PermissionsPanelAction }>;
66
+
67
+ return (
68
+ <SelectionPanel
69
+ focusId={FOCUS_IDS.permissionsPanel}
70
+ title="Permissions"
71
+ subtitle="Inspect or update approval, sandbox, network, and writable-root policy."
72
+ items={items}
73
+ limit={items.length}
74
+ onSelect={(value) => onSelect(value as PermissionsPanelAction)}
75
+ onCancel={onCancel}
76
+ />
77
+ );
78
+ }
@@ -0,0 +1,119 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { Box, Text, useFocus, useInput, useStdin } from "ink";
3
+ import { FOCUS_IDS } from "./focus.js";
4
+ import { useTheme } from "./theme.js";
5
+
6
+ export type PlanActionValue = "implement" | "revise" | "cancel";
7
+
8
+ const ACTION_ROWS: Array<{ key: string; label: string; value: PlanActionValue }> = [
9
+ { key: "I", label: "Implement changes", value: "implement" },
10
+ { key: "U", label: "Update plan", value: "revise" },
11
+ ];
12
+ const VERTICAL_LAYOUT_BREAKPOINT = 56;
13
+
14
+ interface PlanActionPickerProps {
15
+ cols?: number;
16
+ onSelect: (value: PlanActionValue) => void;
17
+ onCancel: () => void;
18
+ }
19
+
20
+ export function measurePlanActionPickerRows(cols = 80): number {
21
+ return cols < VERTICAL_LAYOUT_BREAKPOINT ? 3 : 1;
22
+ }
23
+
24
+ export function PlanActionPicker({
25
+ cols = 80,
26
+ onSelect,
27
+ onCancel,
28
+ }: PlanActionPickerProps) {
29
+ const theme = useTheme();
30
+ const { isFocused } = useFocus({ id: FOCUS_IDS.composer, autoFocus: true });
31
+ const [selectedIndex, setSelectedIndex] = useState(0);
32
+ const { stdin } = useStdin();
33
+ const mouseEventTickRef = useRef(false);
34
+ const mouseEventTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
35
+ const vertical = cols < VERTICAL_LAYOUT_BREAKPOINT;
36
+
37
+ useEffect(() => {
38
+ const handleRawInput = (chunk: Buffer | string) => {
39
+ const raw = typeof chunk === "string" ? chunk : chunk.toString();
40
+ if (/\u001b\[<\d+;\d+;\d+[Mm]/.test(raw) || /\u001b\[M/.test(raw)) {
41
+ mouseEventTickRef.current = true;
42
+ if (mouseEventTimeoutRef.current) clearTimeout(mouseEventTimeoutRef.current);
43
+ mouseEventTimeoutRef.current = setTimeout(() => {
44
+ mouseEventTickRef.current = false;
45
+ }, 32);
46
+ }
47
+ };
48
+ stdin.on("data", handleRawInput);
49
+ return () => {
50
+ stdin.off("data", handleRawInput);
51
+ if (mouseEventTimeoutRef.current) clearTimeout(mouseEventTimeoutRef.current);
52
+ };
53
+ }, [stdin]);
54
+
55
+ useInput((input, key) => {
56
+ if (mouseEventTickRef.current) return;
57
+ if (key.return) {
58
+ onSelect(ACTION_ROWS[selectedIndex]?.value ?? "implement");
59
+ return;
60
+ }
61
+
62
+ if (key.escape) {
63
+ onCancel();
64
+ return;
65
+ }
66
+
67
+ if (key.upArrow || key.leftArrow || (key.shift && key.tab)) {
68
+ setSelectedIndex((current) => (current + ACTION_ROWS.length - 1) % ACTION_ROWS.length);
69
+ return;
70
+ }
71
+
72
+ if (key.downArrow || key.rightArrow || key.tab) {
73
+ setSelectedIndex((current) => (current + 1) % ACTION_ROWS.length);
74
+ return;
75
+ }
76
+
77
+ if (input.length === 1) {
78
+ const lower = input.toLowerCase();
79
+ if (lower === "i") { onSelect("implement"); return; }
80
+ if (lower === "u") { onSelect("revise"); return; }
81
+ }
82
+ }, { isActive: isFocused });
83
+
84
+ const renderAction = (row: (typeof ACTION_ROWS)[number], index: number) => {
85
+ const selected = index === selectedIndex;
86
+ return (
87
+ <Text key={row.value}>
88
+ <Text color={selected ? theme.ACCENT : theme.DIM}>
89
+ {selected ? "› " : vertical ? " " : ""}
90
+ </Text>
91
+ <Text color={selected ? theme.TEXT : theme.MUTED}>
92
+ {`[${row.key}] ${row.label}`}
93
+ </Text>
94
+ </Text>
95
+ );
96
+ };
97
+
98
+ if (vertical) {
99
+ return (
100
+ <Box flexDirection="column">
101
+ <Text color={isFocused ? theme.TEXT : theme.MUTED} bold={isFocused}>Plan ready</Text>
102
+ {ACTION_ROWS.map(renderAction)}
103
+ </Box>
104
+ );
105
+ }
106
+
107
+ return (
108
+ <Text>
109
+ <Text color={isFocused ? theme.TEXT : theme.MUTED} bold={isFocused}>Plan ready</Text>
110
+ <Text color={theme.DIM}>{" "}</Text>
111
+ {ACTION_ROWS.map((row, index) => (
112
+ <React.Fragment key={row.value}>
113
+ {renderAction(row, index)}
114
+ {index < ACTION_ROWS.length - 1 && <Text color={theme.DIM}>{" "}</Text>}
115
+ </React.Fragment>
116
+ ))}
117
+ </Text>
118
+ );
119
+ }