@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
package/src/app.tsx ADDED
@@ -0,0 +1,4561 @@
1
+ import React, { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { spawn } from "child_process";
3
+ import { existsSync } from "fs";
4
+ import path from "node:path";
5
+
6
+ // Diagnostic tracing hook — no-op by default; wire to a real logger when debugging.
7
+ function appDiagLog(msg: string): void {
8
+ void msg;
9
+ }
10
+ import { Box, Text, useApp, useFocusManager, useInput, useStdin, useStdout } from "ink";
11
+ import { handleCommand } from "./commands/handler.js";
12
+ import {
13
+ applyLayeredRuntimeOverride,
14
+ resolveLayeredConfig,
15
+ type LayeredConfigResult,
16
+ } from "./config/layeredConfig.js";
17
+ import type { LaunchArgs } from "./config/launchArgs.js";
18
+ import { loadSettings, saveSettings } from "./config/persistence.js";
19
+ import {
20
+ type AuthPreference,
21
+ type AvailableBackend,
22
+ type AvailableMode,
23
+ type AvailableModel,
24
+ parseBusyLoaderSettingValue,
25
+ type ReasoningLevel,
26
+ type TerminalMouseMode,
27
+ USER_SETTING_DEFINITIONS,
28
+ type WorkspaceDisplayMode,
29
+ type UserSettingValues,
30
+ estimateTokens,
31
+ formatBusyLoaderSettingValue,
32
+ formatAuthPreferenceLabel,
33
+ formatBackendLabel,
34
+ formatWorkspaceDisplayModeLabel,
35
+ formatModeLabel,
36
+ formatReasoningLabel,
37
+ formatThemeLabel,
38
+ formatWorkspaceDisplayPath,
39
+ getNextMode,
40
+ type HeaderConfig,
41
+ type TerminalTitleMode,
42
+ } from "./config/settings.js";
43
+ import {
44
+ AVAILABLE_APPROVAL_POLICIES,
45
+ AVAILABLE_NETWORK_ACCESS_VALUES,
46
+ AVAILABLE_SANDBOX_MODES,
47
+ addWritableRoot,
48
+ buildRuntimeSummary,
49
+ clearWritableRoots,
50
+ diffRuntimeConfig,
51
+ formatApprovalPolicyLabel,
52
+ formatNetworkAccessLabel,
53
+ formatPersonalityLabel,
54
+ formatSandboxModeLabel,
55
+ formatServiceTierLabel,
56
+ mergeRuntimeConfig,
57
+ removeWritableRoot,
58
+ resolveRuntimeConfig,
59
+ resolveWritableRootCommandPath,
60
+ type PartialRuntimeConfig,
61
+ type RuntimeApprovalPolicy,
62
+ type RuntimeConfig,
63
+ type RuntimeNetworkAccess,
64
+ type RuntimePersonality,
65
+ type RuntimeSandboxMode,
66
+ type RuntimeServiceTier,
67
+ } from "./config/runtimeConfig.js";
68
+ import { setProjectTrust } from "./config/trustStore.js";
69
+ import {
70
+ type CodexAuthProbeResult,
71
+ getAuthStatusMessage,
72
+ getLoginGuidance,
73
+ getLogoutGuidance,
74
+ getRunGateDecision,
75
+ isLikelyAuthFailure,
76
+ probeCodexAuthStatus,
77
+ } from "./core/auth/codexAuth.js";
78
+ import { getTerminalSelectionProfile } from "./core/terminal/terminalSelection.js";
79
+ import { copyToClipboard } from "./core/clipboard.js";
80
+ import { normalizePlanReviewMarkdown, savePlan, readPlan } from "./core/planStorage.js";
81
+ import { getBlockedCleanupFailure } from "./core/cleanupFastFail.js";
82
+ import { runShellCommand, summarizeCommandResult } from "./core/process/CommandRunner.js";
83
+ import {
84
+ buildPlanExecutionPrompt,
85
+ buildPlanningPrompt,
86
+ detectHollowResponse,
87
+ isClearlySafeGeneratedCleanupRequest,
88
+ resolveExecutionMode,
89
+ } from "./core/codexPrompt.js";
90
+ import { formatHollowResponse } from "./core/hollowResponseFormat.js";
91
+ import {
92
+ createFallbackModelCapabilities,
93
+ findModelCapability,
94
+ formatModelCapabilitiesList,
95
+ getCodexModelCapabilities,
96
+ getPreferredModelFromCapabilities,
97
+ getSelectableModelCapabilities,
98
+ normalizeReasoningForModelCapabilities,
99
+ type CodexModelCapabilities,
100
+ } from "./core/models/codexModelCapabilities.js";
101
+ import {
102
+ buildDevLaunchNotice,
103
+ buildWorkspaceCommandContext,
104
+ createWorkspaceRelaunchPlan,
105
+ guardWorkspaceRelaunch,
106
+ resolveLaunchContext,
107
+ } from "./core/launchContext.js";
108
+ import {
109
+ findOutsideWorkspacePaths,
110
+ getPromptWorkspaceGuardMessage,
111
+ getShellWorkspaceGuardMessage,
112
+ } from "./core/workspaceGuard.js";
113
+ import {
114
+ type ModelSpec,
115
+ } from "./core/models/modelSpecs.js";
116
+ import {
117
+ contextMetadataToModelSpec,
118
+ formatContextCompact,
119
+ formatContextLength,
120
+ resolveModelContextLength,
121
+ type ModelContextMetadata,
122
+ } from "./core/providerRuntime/contextMetadata.js";
123
+ import { captureWorkspaceSnapshot, createWorkspaceActivityTracker, diffWorkspaceSnapshots } from "./core/workspaceActivity.js";
124
+ import { resolveWorkspaceRoot } from "./core/workspaceRoot.js";
125
+ import {
126
+ importExternalFile,
127
+ isImageFile,
128
+ rewritePromptWithImportedPaths,
129
+ } from "./core/attachments.js";
130
+ import { loadProjectInstructions } from "./core/projectInstructions.js";
131
+ import { isNoiseLine } from "./core/providers/codexTranscript.js";
132
+ import { getBackendProvider } from "./core/providers/registry.js";
133
+ import type { BackendProgressUpdate, BackendProvider } from "./core/providers/types.js";
134
+ import { launchProviderCli } from "./core/providerLauncher/launcher.js";
135
+ import { buildProviderRegistry, findProvider, getActiveRouteProviderId } from "./core/providerLauncher/registry.js";
136
+ import type { ProviderId, ProviderPickerAction, ProviderWorkspaceConfig } from "./core/providerLauncher/types.js";
137
+ import {
138
+ discoverProviderModels,
139
+ getProviderRouteSetupMessage,
140
+ getProviderRuntime,
141
+ isProviderRouteConfigured,
142
+ isProviderRoutableInCodexa,
143
+ resolveActiveProviderRoute,
144
+ validateProviderRouteActivation,
145
+ } from "./core/providerRuntime/registry.js";
146
+ import { hasGeminiApiKey, runGeminiDiagnostics } from "./core/providerRuntime/gemini.js";
147
+ import { checkLocalProvider, runLocalDiagnostics, setLocalProviderConfig } from "./core/providerRuntime/local.js";
148
+ import { validateAnthropicRoute, ANTHROPIC_ROUTE_SETUP_MESSAGE } from "./core/providerRuntime/anthropic.js";
149
+ import { providerModelsToCodexCapabilities } from "./core/providerRuntime/models.js";
150
+ import {
151
+ loadProviderWorkspaceConfig,
152
+ saveProviderWorkspaceConfig,
153
+ setProviderActiveRoute,
154
+ setProviderDefaultReasoning,
155
+ setProviderDefaultModel,
156
+ setProviderWorkspaceDefault,
157
+ } from "./core/providerLauncher/workspaceConfig.js";
158
+ import { sanitizeTerminalInput, sanitizeTerminalLines, sanitizeTerminalOutput } from "./core/terminal/terminalSanitize.js";
159
+ import { createTerminalModeController, setTerminalControlUIState } from "./core/terminal/terminalControl.js";
160
+ import {
161
+ acquireTerminalTitleGuard,
162
+ beginColdStartSequence,
163
+ deriveTerminalTitle,
164
+ reassertIntendedTerminalTitle,
165
+ refreshTerminalTitle,
166
+ setTerminalTitleLifecycleState,
167
+ setIntendedTerminalTitle,
168
+ } from "./core/terminal/terminalTitle.js";
169
+ import { getStdinDebugState, traceInputDebug } from "./core/inputDebug.js";
170
+ import * as perf from "./core/perf/profiler.js";
171
+ import * as renderDebug from "./core/perf/renderDebug.js";
172
+ import {
173
+ checkGhCli,
174
+ checkLocalGitRemote,
175
+ checkLocalGitWrite,
176
+ classifyDiagnostics,
177
+ getLocalGitRemoteUrl,
178
+ parseRepoIdentity,
179
+ type DiagnosticResult,
180
+ } from "./core/githubDiagnostics.js";
181
+ import type { RunEvent, Screen, ShellEvent, TimelineEvent, UIState, UserPromptEvent } from "./session/types.js";
182
+ import {
183
+ buildFollowUpPrompt,
184
+ createRunEvent,
185
+ extractAssistantActionRequired,
186
+ guardConfigMutation,
187
+ isCurrentRun,
188
+ } from "./session/chatLifecycle.js";
189
+ import { findUserPrompt, useAppSessionState } from "./session/appSession.js";
190
+ import { createLiveRenderScheduler, type LiveRenderUpdate } from "./session/liveRenderScheduler.js";
191
+ import { hasFinalizedTranscriptPlan } from "./session/planTranscript.js";
192
+ import { schedulePromptRunStartAfterVisibleCommit } from "./session/promptRunSchedule.js";
193
+ import {
194
+ approvePlanExecution,
195
+ beginPlanFeedback,
196
+ cancelPlanFeedback,
197
+ createInitialPlanFlowState,
198
+ finishPlanGeneration,
199
+ resetPlanFlow,
200
+ startPlanGeneration,
201
+ submitPlanFeedback,
202
+ type PlanFlowState,
203
+ } from "./session/planFlow.js";
204
+ import { AuthPanel } from "./ui/AuthPanel.js";
205
+ import { BackendPicker } from "./ui/BackendPicker.js";
206
+ import { measureBottomComposerRows, MemoizedBottomComposer } from "./ui/BottomComposer.js";
207
+ import { useTerminalViewport } from "./ui/layout.js";
208
+ import { ModelPickerScreen } from "./ui/ModelPickerScreen.js";
209
+ import { ModePicker } from "./ui/ModePicker.js";
210
+ import { PlanActionPicker, type PlanActionValue, measurePlanActionPickerRows } from "./ui/PlanActionPicker.js";
211
+ import { PermissionsPanel, type PermissionsPanelAction } from "./ui/PermissionsPanel.js";
212
+ import { ProviderPicker } from "./ui/ProviderPicker.js";
213
+ import { ReasoningPicker } from "./ui/ReasoningPicker.js";
214
+ import { AttachmentImportPanel, type PendingImportFile } from "./ui/AttachmentImportPanel.js";
215
+ import { SelectionPanel } from "./ui/SelectionPanel.js";
216
+ import { SettingsPanel } from "./ui/SettingsPanel.js";
217
+ import { measureTextEntryPanelRows, TextEntryPanel } from "./ui/TextEntryPanel.js";
218
+ import { ThemePicker } from "./ui/ThemePicker.js";
219
+ import { getFocusTargetForScreen, FOCUS_IDS } from "./ui/focus.js";
220
+ import { ThemeProvider, THEMES } from "./ui/theme.js";
221
+ import {
222
+ cancelThemeSelection,
223
+ commitThemeSelection,
224
+ getDisplayedThemeName,
225
+ previewThemeSelection,
226
+ shouldBumpComposerInstance,
227
+ type ThemeSelectionState,
228
+ } from "./ui/themeFlow.js";
229
+ import { isBusy as isUiBusy } from "./session/types.js";
230
+ import { AppShell } from "./ui/AppShell.js";
231
+
232
+ // ─── Module Constants & Helpers ────────────────────────────────────────────────
233
+
234
+ let nextEventId = 0;
235
+ let nextTurnId = 0;
236
+ // 50ms keeps assistant text live while avoiding frame-wide terminal repaint
237
+ // churn during streaming/action updates.
238
+ const LIVE_UPDATE_FLUSH_MS = 50;
239
+ const PROGRESS_ONLY_FLUSH_MS = 175;
240
+
241
+ function formatWritableRootsMessage(roots: readonly string[]): string {
242
+ return roots.length > 0
243
+ ? roots.map((root) => ` - ${root}`).join("\n")
244
+ : " - none";
245
+ }
246
+
247
+ function createEventId(): number {
248
+ return nextEventId++;
249
+ }
250
+
251
+ function createTurnId(): number {
252
+ return nextTurnId++;
253
+ }
254
+
255
+ function createInitialAuthStatus(): CodexAuthProbeResult {
256
+ return {
257
+ state: "checking",
258
+ checkedAt: 0,
259
+ rawSummary: "Auth check pending.",
260
+ recommendedAction: "Run /auth status to check sign-in state.",
261
+ };
262
+ }
263
+
264
+ interface AppProps {
265
+ launchArgs: LaunchArgs;
266
+ }
267
+
268
+ interface PromptRunTiming {
269
+ submitEpochMs: number;
270
+ submitMonotonicMs: number;
271
+ }
272
+
273
+ interface PromptRunLifecycle {
274
+ parseActionRequired?: boolean;
275
+ disableModeAutoUpgrade?: boolean;
276
+ runtimeOverride?: PartialRuntimeConfig;
277
+ responsePresentation?: "assistant" | "plan";
278
+ approvedPlan?: string;
279
+ submitTiming?: PromptRunTiming;
280
+ commitPrompt?: boolean;
281
+ onCompleted?: (result: { response: string; turnId: number; runId: number }) => void;
282
+ onFailed?: (result: { message: string; turnId: number; runId: number }) => void;
283
+ onCanceled?: (result: { turnId: number; runId: number }) => void;
284
+ }
285
+
286
+ function createPromptRunTiming(): PromptRunTiming {
287
+ return {
288
+ submitEpochMs: Date.now(),
289
+ submitMonotonicMs: performance.now(),
290
+ };
291
+ }
292
+
293
+ export function App({ launchArgs }: AppProps) {
294
+ const { exit } = useApp();
295
+ const focusManager = useFocusManager();
296
+ const workspaceRoot = useMemo(() => resolveWorkspaceRoot(), []);
297
+ const projectInstructionsLoad = useMemo(() => loadProjectInstructions(workspaceRoot), [workspaceRoot]);
298
+ const projectInstructions = projectInstructionsLoad.status === "loaded"
299
+ ? projectInstructionsLoad.instructions
300
+ : null;
301
+ const initialSettings = useRef(loadSettings());
302
+ const initialProviderWorkspaceConfig = useRef<ProviderWorkspaceConfig>(loadProviderWorkspaceConfig(workspaceRoot));
303
+ const initialLayeredConfig = useRef<LayeredConfigResult | null>(null);
304
+ if (initialLayeredConfig.current === null) {
305
+ initialLayeredConfig.current = resolveLayeredConfig({ workspaceRoot, launchArgs });
306
+ }
307
+ const launchContext = useMemo(
308
+ () => resolveLaunchContext({ workspaceRoot, forwardArgs: launchArgs.passthroughArgs }),
309
+ [launchArgs.passthroughArgs, workspaceRoot],
310
+ );
311
+ const workspaceCommandContext = useMemo(
312
+ () => buildWorkspaceCommandContext(launchContext),
313
+ [launchContext],
314
+ );
315
+ const terminalLayout = useTerminalViewport();
316
+
317
+ // ─── State & Refs ────────────────────────────────────────────────────────────
318
+
319
+ const [baseLayeredConfig, setBaseLayeredConfig] = useState<LayeredConfigResult>(initialLayeredConfig.current);
320
+ const [sessionRuntimeOverride, setSessionRuntimeOverride] = useState<PartialRuntimeConfig>(() => {
321
+ const initialRoute = initialProviderWorkspaceConfig.current.activeRoute;
322
+ if (!initialRoute || !isProviderRoutableInCodexa(initialRoute.providerId)) {
323
+ return {};
324
+ }
325
+
326
+ // When --model was given on the CLI, that arg must win over the persisted activeRoute
327
+ // model so that explicit test/benchmark model flags are actually honoured for the session.
328
+ const cliModel = launchArgs.modelOverride;
329
+ return {
330
+ model: cliModel ?? initialRoute.modelId,
331
+ ...(initialRoute.reasoning ? { reasoningLevel: initialRoute.reasoning } : {}),
332
+ };
333
+ });
334
+ const [authPreference, setAuthPreference] = useState<AuthPreference>(initialSettings.current.auth.preference);
335
+ const [workspaceDisplayMode, setWorkspaceDisplayMode] = useState<WorkspaceDisplayMode>(
336
+ initialSettings.current.ui.workspaceDisplayMode,
337
+ );
338
+ const [terminalTitleMode, setTerminalTitleMode] = useState<TerminalTitleMode>(
339
+ initialSettings.current.ui.terminalTitleMode,
340
+ );
341
+ const [showBusyLoader, setShowBusyLoader] = useState(
342
+ initialSettings.current.ui.showBusyLoader,
343
+ );
344
+ const [terminalMouseMode, setTerminalMouseMode] = useState<TerminalMouseMode>(
345
+ initialSettings.current.ui.terminalMouseMode,
346
+ );
347
+ const [providerWorkspaceConfig, setProviderWorkspaceConfig] = useState<ProviderWorkspaceConfig>(
348
+ initialProviderWorkspaceConfig.current,
349
+ );
350
+ const [pendingRouteProviderId, setPendingRouteProviderId] = useState<ProviderId | null>(null);
351
+ const [themeSelection, setThemeSelection] = useState<ThemeSelectionState>({
352
+ committedTheme: initialSettings.current.ui.theme,
353
+ previewTheme: null,
354
+ });
355
+ const [customTheme, setCustomTheme] = useState(initialSettings.current.ui.customTheme);
356
+ const [headerConfig] = useState(initialSettings.current.header);
357
+ const [screen, setScreen] = useState<Screen>("main");
358
+ const [pendingImport, setPendingImport] = useState<{
359
+ prompt: string;
360
+ files: PendingImportFile[];
361
+ attachmentsDir: string;
362
+ } | null>(null);
363
+ const [registryNonce, setRegistryNonce] = useState(0);
364
+ const screenRef = useRef<Screen>("main");
365
+ screenRef.current = screen;
366
+ const [composerInstanceKey, setComposerInstanceKey] = useState(0);
367
+ const { state: sessionState, dispatch: dispatchSession } = useAppSessionState();
368
+ const [authStatus, setAuthStatus] = useState<CodexAuthProbeResult>(createInitialAuthStatus());
369
+ const [authStatusBusy, setAuthStatusBusy] = useState(false);
370
+ // Running character total across the conversation — used to estimate token usage
371
+ const [conversationChars, setConversationChars] = useState(0);
372
+ const [modelCapabilities, setModelCapabilities] = useState<CodexModelCapabilities | null>(null);
373
+ const [modelCapabilitiesBusy, setModelCapabilitiesBusy] = useState(false);
374
+ const [activeContextMetadata, setActiveContextMetadata] = useState<ModelContextMetadata | null>(null);
375
+ const { stdout } = useStdout();
376
+ const { stdin } = useStdin();
377
+ const terminalControl = useMemo(() => createTerminalModeController((chunk) => stdout.write(chunk)), [stdout]);
378
+ const [mouseOverride, setMouseOverride] = useState<boolean | null>(null);
379
+ const [isMouseIdle, setIsMouseIdle] = useState(false);
380
+ const mouseIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
381
+
382
+ const resetMouseIdle = useCallback(() => {
383
+ setIsMouseIdle(false);
384
+ if (mouseIdleTimerRef.current) {
385
+ clearTimeout(mouseIdleTimerRef.current);
386
+ }
387
+ mouseIdleTimerRef.current = setTimeout(() => {
388
+ setIsMouseIdle(true);
389
+ }, 1500);
390
+ }, []);
391
+
392
+ useInput(() => {
393
+ // Any keyboard activity re-enables mouse tracking if it was idle.
394
+ resetMouseIdle();
395
+ });
396
+
397
+ const [verboseMode, setVerboseMode] = useState(false);
398
+ const [planFlow, setPlanFlow] = useState<PlanFlowState>(createInitialPlanFlowState);
399
+ const [initialRevisionText, setInitialRevisionText] = useState("");
400
+ // Mouse reporting defaults to the persisted terminalMouseMode setting.
401
+ // "wheel" (default) enables SGR mouse reporting so the timeline can receive
402
+ // wheel events; native drag-select then requires Shift in Windows Terminal.
403
+ // "selection" keeps the terminal in control of mouse events.
404
+ // /mouse toggles the in-session override without changing the saved setting.
405
+ //
406
+ // We apply an idle-timeout: if no wheel events or keypresses occur for 1.5s,
407
+ // we disable mouse reporting so native drag-selection works unmodified.
408
+ const mouseCapture = (mouseOverride ?? (terminalMouseMode === "wheel")) && !isMouseIdle;
409
+
410
+ // ─── Effects & Handlers ──────────────────────────────────────────────────────
411
+
412
+ useEffect(() => {
413
+ // Default path writes the disable sequences defensively, preserving native
414
+ // terminal selection unless the user explicitly opts into /mouse capture.
415
+ terminalControl.setMouseReporting(mouseCapture, mouseCapture ? "src/app.tsx:mouseCapture.enable" : "src/app.tsx:mouseCapture.disable");
416
+ return () => {
417
+ terminalControl.setMouseReporting(false, "src/app.tsx:mouseCapture.cleanup");
418
+ };
419
+ }, [mouseCapture, terminalControl]);
420
+
421
+ const cleanupRef = useRef<(() => void) | null>(null);
422
+ const activeRunLifecycleRef = useRef<PromptRunLifecycle | null>(null);
423
+ const activeRunTimingRef = useRef<(PromptRunTiming & { runId: number; turnId: number }) | null>(null);
424
+ const isMountedRef = useRef(true);
425
+ const activeRunIdRef = useRef<number | null>(null);
426
+ const activeTurnIdRef = useRef<number | null>(null);
427
+ const clearEpochRef = useRef<number>(0); // Incremented on /clear to suppress stale command events
428
+ const externalCliStatusRef = useRef(sessionState.externalCliStatus);
429
+ const previousScreenRef = useRef<Screen>("main");
430
+ const themePreviewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
431
+ const modelDiscoveryInFlightRef = useRef<Promise<CodexModelCapabilities> | null>(null);
432
+ const modelDiscoveryAnnounceRef = useRef(false);
433
+ const intendedInputModeRef = useRef<"chat/input" | "model-picker">("chat/input");
434
+ const intendedFocusTargetRef = useRef<string>(FOCUS_IDS.composer);
435
+ const modelSelectionInFlightRef = useRef(false);
436
+ const providerRouteErrorsRef = useRef<Record<string, string>>({});
437
+ const providerDiagnosticsRef = useRef<Record<string, Record<string, string | number | boolean | null>>>({});
438
+ const providerMigrationNoticeShownRef = useRef(false);
439
+ const initialPromptSubmittedRef = useRef(false);
440
+ const activeThemeName = getDisplayedThemeName(themeSelection);
441
+ const activeTheme =
442
+ activeThemeName === "custom"
443
+ ? { ...THEMES.purple, ...customTheme }
444
+ : (THEMES[activeThemeName] ?? THEMES.purple);
445
+ const baseRuntimeConfigRef = useRef(baseLayeredConfig.runtime);
446
+ const layeredRuntimeConfig = useMemo(
447
+ () => applyLayeredRuntimeOverride(baseLayeredConfig, sessionRuntimeOverride, "In-session overrides"),
448
+ [baseLayeredConfig, sessionRuntimeOverride],
449
+ );
450
+ const runtimeConfig = layeredRuntimeConfig.runtime;
451
+ const { provider: backend, model, mode, reasoningLevel, planMode } = runtimeConfig;
452
+ const resolvedRuntimeConfig = useMemo(() => resolveRuntimeConfig(runtimeConfig), [runtimeConfig]);
453
+ const runtimeSummary = useMemo(() => buildRuntimeSummary(resolvedRuntimeConfig), [resolvedRuntimeConfig]);
454
+ const activeProviderRoute = useMemo(() => {
455
+ // When --model was given on the CLI, override the stored activeRoute's modelId so
456
+ // the actual run uses the CLI model instead of whatever is persisted in providers.json.
457
+ // The providers.json entry is left unchanged so it survives this session.
458
+ const cliModel = launchArgs.modelOverride;
459
+ const configuredRoute = providerWorkspaceConfig.activeRoute;
460
+ const effectiveRoute = cliModel && configuredRoute
461
+ ? { ...configuredRoute, modelId: cliModel }
462
+ : configuredRoute;
463
+ return resolveActiveProviderRoute({
464
+ workspaceConfigActiveRoute: effectiveRoute,
465
+ currentModel: model,
466
+ currentReasoning: reasoningLevel,
467
+ });
468
+ }, [launchArgs.modelOverride, model, providerWorkspaceConfig.activeRoute, reasoningLevel, registryNonce]);
469
+ const activeProviderRuntime = useMemo(
470
+ () => getProviderRuntime(activeProviderRoute.providerId),
471
+ [activeProviderRoute.providerId],
472
+ );
473
+ const providerRegistry = useMemo(
474
+ () => {
475
+ setLocalProviderConfig(providerWorkspaceConfig.providers?.local);
476
+ return buildProviderRegistry({
477
+ activeModel: model,
478
+ workspaceConfig: providerWorkspaceConfig,
479
+ diagnostics: providerDiagnosticsRef.current,
480
+ routeErrors: providerRouteErrorsRef.current,
481
+ });
482
+ },
483
+ // registryNonce is intentionally included: startup probes and post-validation
484
+ // updates mutate providerDiagnosticsRef/providerRouteErrorsRef (refs, not state)
485
+ // and then increment the nonce to trigger a re-read of those refs.
486
+ // eslint-disable-next-line react-hooks/exhaustive-deps
487
+ [model, providerWorkspaceConfig, registryNonce],
488
+ );
489
+ const workspaceDefaultProvider = useMemo(
490
+ () => providerRegistry.find((provider) => provider.isDefault) ?? providerRegistry[0] ?? null,
491
+ [providerRegistry],
492
+ );
493
+ const activeRouteProviderId = activeProviderRoute.providerId;
494
+
495
+ // Reset provider readiness when the user switches to a different provider.
496
+ useEffect(() => {
497
+ dispatchSession({ type: "SET_EXTERNAL_CLI_STATUS", status: "idle" });
498
+ }, [activeRouteProviderId]); // eslint-disable-line react-hooks/exhaustive-deps
499
+
500
+ const activeRouteProvider = useMemo(
501
+ () => findProvider(providerRegistry, activeRouteProviderId) ?? providerRegistry[0] ?? null,
502
+ [activeRouteProviderId, providerRegistry],
503
+ );
504
+ const modelPickerProviderId = pendingRouteProviderId ?? activeProviderRoute.providerId;
505
+ const modelPickerRuntime = useMemo(
506
+ () => getProviderRuntime(modelPickerProviderId),
507
+ [modelPickerProviderId],
508
+ );
509
+ const modelPickerDiscovery = useMemo(() => {
510
+ if (modelPickerProviderId === "openai") return null;
511
+ return discoverProviderModels(modelPickerProviderId);
512
+ }, [modelPickerProviderId]);
513
+ const providerModelCapabilities = useMemo(() => {
514
+ if (!modelPickerDiscovery) return null;
515
+ return providerModelsToCodexCapabilities(modelPickerDiscovery.models, activeProviderRoute.modelId);
516
+ }, [activeProviderRoute.modelId, modelPickerDiscovery]);
517
+ const activeRouteModelCapabilities = useMemo(() => {
518
+ if (activeProviderRoute.providerId === "openai") return modelCapabilities;
519
+ const discovery = discoverProviderModels(activeProviderRoute.providerId);
520
+ return providerModelsToCodexCapabilities(discovery.models, activeProviderRoute.modelId);
521
+ }, [activeProviderRoute.modelId, activeProviderRoute.providerId, modelCapabilities]);
522
+ const modelPickerModels = useMemo(
523
+ () => {
524
+ if (providerModelCapabilities) {
525
+ return getSelectableModelCapabilities(providerModelCapabilities);
526
+ }
527
+ if (modelPickerProviderId === "openai") {
528
+ return getSelectableModelCapabilities(modelCapabilities ?? createFallbackModelCapabilities(null));
529
+ }
530
+ return [];
531
+ },
532
+ [modelCapabilities, modelPickerProviderId, providerModelCapabilities],
533
+ );
534
+ const modelPickerCurrentModel = useMemo(() => {
535
+ if (!pendingRouteProviderId) return activeProviderRoute.modelId;
536
+ if (pendingRouteProviderId === activeProviderRoute.providerId) return activeProviderRoute.modelId;
537
+ // Non-active provider: use stored default, fall back to first selectable model
538
+ const storedDefault = providerWorkspaceConfig.providers?.[pendingRouteProviderId]?.currentModel;
539
+ const selectable = getSelectableModelCapabilities(
540
+ providerModelCapabilities ?? createFallbackModelCapabilities(null),
541
+ );
542
+ return storedDefault ?? selectable[0]?.model ?? model;
543
+ }, [activeProviderRoute, model, pendingRouteProviderId, providerModelCapabilities, providerWorkspaceConfig]);
544
+ const modelPickerCurrentReasoning = useMemo(() => {
545
+ if (!pendingRouteProviderId) {
546
+ return activeProviderRoute.reasoning ?? reasoningLevel;
547
+ }
548
+ if (pendingRouteProviderId === activeProviderRoute.providerId) {
549
+ return activeProviderRoute.reasoning ?? reasoningLevel;
550
+ }
551
+ const storedReasoning = providerWorkspaceConfig.providers?.[pendingRouteProviderId]?.currentReasoning;
552
+ if (storedReasoning) return storedReasoning;
553
+ const pickerCapabilities = pendingRouteProviderId === "openai" ? modelCapabilities : providerModelCapabilities;
554
+ const capability = findModelCapability(pickerCapabilities, modelPickerCurrentModel);
555
+ return capability?.defaultReasoningLevel
556
+ ?? capability?.supportedReasoningLevels?.[0]?.id
557
+ ?? reasoningLevel;
558
+ }, [
559
+ activeProviderRoute.providerId,
560
+ activeProviderRoute.reasoning,
561
+ modelPickerCurrentModel,
562
+ modelCapabilities,
563
+ pendingRouteProviderId,
564
+ providerModelCapabilities,
565
+ providerWorkspaceConfig.providers,
566
+ reasoningLevel,
567
+ ]);
568
+ const modelPickerProviderLabel = useMemo(
569
+ () => modelPickerRuntime.modelPickerLabel
570
+ ?? findProvider(providerRegistry, modelPickerProviderId)?.displayName
571
+ ?? modelPickerRuntime.label,
572
+ [modelPickerProviderId, modelPickerRuntime, providerRegistry],
573
+ );
574
+ const modelPickerEmptyMessage = useMemo(() => {
575
+ if (modelPickerProviderId === "openai") {
576
+ return modelCapabilities?.error ? `No models available: ${modelCapabilities.error}` : "No models available.";
577
+ }
578
+ if (modelPickerDiscovery?.status === "ready" && modelPickerDiscovery.models.length === 0) {
579
+ const setup = getProviderRouteSetupMessage(modelPickerProviderId);
580
+ return `No ${modelPickerProviderLabel} models available because the route is not configured. ${setup}`;
581
+ }
582
+ return modelPickerDiscovery?.message ?? "No models available.";
583
+ }, [modelCapabilities, modelPickerDiscovery, modelPickerProviderId, modelPickerProviderLabel]);
584
+ const routeStatusMessage = useMemo(() => {
585
+ const providerLines = providerRegistry.map((provider) => {
586
+ const runtime = getProviderRuntime(provider.id);
587
+ const discovery = runtime.discoverModels();
588
+ const routingStatus = runtime.routeAvailable
589
+ ? isProviderRouteConfigured(provider.id) ? "configured" : "not configured"
590
+ : "unavailable";
591
+
592
+ let line = ` ${provider.displayName} routing: ${routingStatus} (${discovery.backendKind})`;
593
+
594
+ const diagnostics = providerDiagnosticsRef.current[provider.id];
595
+ if (diagnostics && provider.id === "google") {
596
+ const lines = [line];
597
+ if (diagnostics.resolvedCommand ?? diagnostics.executablePath) lines.push(` Resolved command: ${diagnostics.resolvedCommand ?? diagnostics.executablePath}`);
598
+ if (diagnostics.version) lines.push(` Version: ${diagnostics.version}`);
599
+ if (diagnostics.headlessPromptMode) lines.push(` Headless prompt mode: ${diagnostics.headlessPromptMode}`);
600
+ lines.push(` Status: ${diagnostics.probeStatus ?? (diagnostics.status === "completed" && diagnostics.exitCode === 0 && diagnostics.probeMatch ? "Ready" : "failed")}`);
601
+ if (diagnostics.lastProbeCommandArgs) lines.push(` Last probe command args: ${diagnostics.lastProbeCommandArgs}`);
602
+ if (diagnostics.status !== "completed" || diagnostics.exitCode !== 0 || !diagnostics.probeMatch) {
603
+ const reason = diagnostics.failureReason ?? (diagnostics.timeout ? "timeout" : "unknown");
604
+ lines.push(` Reason: ${reason}`);
605
+ if (diagnostics.firstUsefulOutputLine) lines.push(` First output: ${diagnostics.firstUsefulOutputLine}`);
606
+ }
607
+ lines.push(` API fallback: ${hasGeminiApiKey() ? "available" : "unavailable"}`);
608
+ line = lines.join("\n");
609
+ }
610
+
611
+ if (diagnostics && provider.id === "local") {
612
+ const lines = [line];
613
+ lines.push(` Base URL: ${diagnostics.baseUrl ?? "unknown"}`);
614
+ lines.push(` Selected model: ${diagnostics.selectedModel ?? "none"}`);
615
+ lines.push(` Models: ${diagnostics.discoveredModels || "none"}`);
616
+ lines.push(` Endpoint check: ${diagnostics.endpointCheckResult ?? "unknown"}`);
617
+ if (diagnostics.errorMessage) lines.push(` Error: ${diagnostics.errorMessage}`);
618
+ line = lines.join("\n");
619
+ }
620
+
621
+ if (diagnostics?.contextSource || diagnostics?.contextError) {
622
+ const contextLength = typeof diagnostics.contextLength === "number"
623
+ ? diagnostics.contextLength
624
+ : null;
625
+ const lines = line.split("\n");
626
+ lines.push(` Context: ${formatContextLength(contextLength)}`);
627
+ lines.push(` Context source: ${diagnostics.contextSource ?? "unknown"}`);
628
+ lines.push(` Context confidence: ${diagnostics.contextConfidence ?? "unknown"}`);
629
+ if (diagnostics.contextRawField) lines.push(` Context field: ${diagnostics.contextRawField}`);
630
+ if (diagnostics.contextError) lines.push(` Context reason: ${diagnostics.contextError}`);
631
+ line = lines.join("\n");
632
+ }
633
+
634
+ return line;
635
+ });
636
+
637
+ const activeModelInfo = activeProviderRoute.providerId === "google" && activeProviderRoute.modelSelection
638
+ ? (activeProviderRoute.modelSelection.kind === "auto"
639
+ ? `Auto (${activeProviderRoute.modelSelection.family === "gemini-3" ? "Gemini 3" : "Gemini 2.5"}) -> ${activeProviderRoute.modelId}`
640
+ : activeProviderRoute.modelId)
641
+ : activeProviderRoute.modelId;
642
+
643
+ const ctxValue = activeContextMetadata?.contextLength != null
644
+ ? `${activeContextMetadata.confidence === "estimated" ? "~" : ""}${formatContextCompact(activeContextMetadata.contextLength)}`
645
+ : "Unknown";
646
+ const ctxSource = activeContextMetadata?.source && activeContextMetadata.source !== "unknown"
647
+ ? ` (${activeContextMetadata.source})`
648
+ : "";
649
+
650
+ return [
651
+ "Route status:",
652
+ ` Workspace default provider: ${workspaceDefaultProvider?.displayName ?? "OpenAI"}`,
653
+ ` Active chat route: ${activeRouteProvider?.displayName ?? "OpenAI"} / ${activeModelInfo}`,
654
+ ` Context: ${ctxValue}${ctxSource}`,
655
+ ` Backend kind: ${activeProviderRoute.backendKind}`,
656
+ ` In-Codexa routing: ${activeProviderRuntime.routeAvailable ? isProviderRouteConfigured(activeProviderRoute.providerId) ? "configured" : "not configured" : "unavailable"}`,
657
+ ` External launch: ${activeRouteProvider?.launchCommand ? "Available" : "Unavailable"}`,
658
+ ...(providerLines.length > 0 ? providerLines : []),
659
+ ].join("\n");
660
+ }, [activeContextMetadata, activeProviderRoute.backendKind, activeProviderRoute.modelId, activeProviderRoute.modelSelection, activeProviderRoute.providerId, activeProviderRoute.reasoning, activeProviderRuntime.routeAvailable, activeRouteModelCapabilities, activeRouteProvider, providerRegistry, reasoningLevel, workspaceDefaultProvider]);
661
+ const selectionProfile = useMemo(
662
+ () => getTerminalSelectionProfile(process.env),
663
+ [],
664
+ );
665
+ const selectableModelCapabilities = useMemo(
666
+ () => activeRouteModelCapabilities ? getSelectableModelCapabilities(activeRouteModelCapabilities) : [],
667
+ [activeRouteModelCapabilities],
668
+ );
669
+ const currentModelCapability = useMemo(
670
+ () => findModelCapability(activeRouteModelCapabilities, activeProviderRoute.modelId),
671
+ [activeProviderRoute.modelId, activeRouteModelCapabilities],
672
+ );
673
+ const currentModelRawMetadataKey = useMemo(
674
+ () => currentModelCapability?.raw === undefined ? "" : JSON.stringify(currentModelCapability.raw),
675
+ [currentModelCapability?.raw],
676
+ );
677
+ const currentReasoningCapabilities = currentModelCapability?.supportedReasoningLevels ?? [];
678
+ const currentReasoningSourceLabel = useMemo(() => {
679
+ if (activeProviderRoute.providerId !== "anthropic") return null;
680
+ const raw = currentModelCapability?.raw as { source?: string; effortVerified?: boolean } | null | undefined;
681
+ if (raw?.source === "claude-code" || raw?.source === "discovered") return "Discovered from Claude Code";
682
+ if (raw?.source === "settings" || raw?.source === "config") return "From Claude settings";
683
+ return raw?.effortVerified === false ? "Fallback defaults; unverified" : "Fallback defaults";
684
+ }, [activeProviderRoute.providerId, currentModelCapability]);
685
+
686
+ useEffect(() => {
687
+ let cancelled = false;
688
+ const providerId = activeProviderRoute.providerId;
689
+ const modelId = activeProviderRoute.modelId;
690
+ const rawMetadata = currentModelCapability?.raw;
691
+
692
+ void resolveModelContextLength({
693
+ providerId,
694
+ modelId,
695
+ providerConfig: providerWorkspaceConfig.providers?.[providerId],
696
+ rawMetadata,
697
+ }).then((metadata) => {
698
+ if (cancelled || !isMountedRef.current) return;
699
+ setActiveContextMetadata(metadata);
700
+
701
+ const contextSource = metadata.source === "known-registry" ? "registry" : metadata.source;
702
+ providerDiagnosticsRef.current[providerId] = {
703
+ ...(providerDiagnosticsRef.current[providerId] ?? {}),
704
+ contextLength: metadata.contextLength,
705
+ contextSource,
706
+ contextConfidence: metadata.confidence,
707
+ contextRawField: metadata.rawField ?? null,
708
+ contextError: metadata.error ?? null,
709
+ };
710
+ setRegistryNonce((current) => current + 1);
711
+ });
712
+
713
+ return () => {
714
+ cancelled = true;
715
+ };
716
+ }, [
717
+ activeProviderRoute.modelId,
718
+ activeProviderRoute.providerId,
719
+ currentModelRawMetadataKey,
720
+ providerWorkspaceConfig.providers,
721
+ ]);
722
+
723
+ const workspaceLabel = useMemo(
724
+ () => formatWorkspaceDisplayPath(workspaceRoot, workspaceDisplayMode),
725
+ [workspaceDisplayMode, workspaceRoot],
726
+ );
727
+ const terminalTitleLabel = useMemo(
728
+ () => deriveTerminalTitle(workspaceRoot, terminalTitleMode),
729
+ [terminalTitleMode, workspaceRoot],
730
+ );
731
+ const latestTerminalTitleRef = useRef(terminalTitleLabel);
732
+ latestTerminalTitleRef.current = terminalTitleLabel;
733
+
734
+ // Cold-start: write title immediately on mount, then retry to outlast Windows Terminal shell integration.
735
+ // eslint-disable-next-line react-hooks/exhaustive-deps
736
+ useEffect(() => beginColdStartSequence(latestTerminalTitleRef.current), []);
737
+
738
+ const { staticEvents, activeEvents, uiState, inputValue, cursor } = sessionState;
739
+
740
+ const currentUserSettings = useMemo<UserSettingValues>(() => ({
741
+ workspaceDisplayMode,
742
+ terminalTitleMode,
743
+ showBusyLoader: formatBusyLoaderSettingValue(showBusyLoader),
744
+ terminalMouseMode,
745
+ }), [showBusyLoader, terminalMouseMode, terminalTitleMode, workspaceDisplayMode]);
746
+
747
+ const allowedWritableRoots = useMemo(
748
+ () => resolvedRuntimeConfig.policy.writableRoots,
749
+ [resolvedRuntimeConfig],
750
+ );
751
+
752
+ const hasPlanFileAvailable = useMemo(
753
+ () => planFlow.kind !== "idle"
754
+ && planFlow.planFilePath !== null
755
+ && existsSync(planFlow.planFilePath),
756
+ [planFlow],
757
+ );
758
+
759
+ const currentModelSpec = useMemo<ModelSpec>(() => {
760
+ return contextMetadataToModelSpec(activeContextMetadata ?? {
761
+ providerId: activeProviderRoute.providerId,
762
+ modelId: activeProviderRoute.modelId,
763
+ contextLength: null,
764
+ source: "unknown",
765
+ confidence: "unknown",
766
+ error: "Context length unavailable for this model.",
767
+ });
768
+ }, [activeContextMetadata, activeProviderRoute.modelId, activeProviderRoute.providerId]);
769
+
770
+ const hasUserPrompt = useMemo(
771
+ () => staticEvents.some((e) => e.type === "user") || activeEvents.some((e) => e.type === "user"),
772
+ [staticEvents, activeEvents],
773
+ );
774
+
775
+ const hasVisibleTranscriptPlan = useMemo(
776
+ () => planFlow.kind === "awaiting_action"
777
+ && hasFinalizedTranscriptPlan(staticEvents, planFlow.currentPlan),
778
+ [planFlow, staticEvents],
779
+ );
780
+
781
+ // Refs for mutable state values — used by stable callbacks below so they
782
+ // always read the latest value without being listed as deps (which would
783
+ // recreate the callbacks on every keystroke and defeat memoisation).
784
+ const inputValueRef = useRef(inputValue);
785
+ inputValueRef.current = inputValue;
786
+ const cursorRef = useRef(cursor);
787
+ cursorRef.current = cursor;
788
+
789
+ const busy = isUiBusy(uiState);
790
+ const busyRef = useRef(busy);
791
+ busyRef.current = busy;
792
+
793
+ const forceRefreshCurrentTerminalTitle = useCallback((debugEventName: string, busyState = busyRef.current) => {
794
+ refreshTerminalTitle({
795
+ terminalTitleMode,
796
+ workspaceName: deriveTerminalTitle(workspaceRoot, "dir"),
797
+ force: true,
798
+ debugEventName,
799
+ busyState,
800
+ });
801
+ }, [terminalTitleMode, workspaceRoot]);
802
+
803
+ const writeCurrentTerminalTitleBeforeStateChange = useCallback((reason: string) => {
804
+ setIntendedTerminalTitle(deriveTerminalTitle(workspaceRoot, terminalTitleMode), {
805
+ force: true,
806
+ reason,
807
+ });
808
+ }, [terminalTitleMode, workspaceRoot]);
809
+
810
+ // Re-assert terminal title whenever the app transitions between busy states,
811
+ // starts/stops streaming, or executes tools to recover from external overwrites.
812
+ useEffect(() => {
813
+ if (!busy) {
814
+ refreshTerminalTitle({
815
+ terminalTitleMode,
816
+ workspaceName: deriveTerminalTitle(workspaceRoot, "dir"),
817
+ force: true,
818
+ debugEventName: "busy-end",
819
+ busyState: false,
820
+ });
821
+ return;
822
+ }
823
+
824
+ refreshTerminalTitle({
825
+ terminalTitleMode,
826
+ workspaceName: deriveTerminalTitle(workspaceRoot, "dir"),
827
+ force: true,
828
+ debugEventName: "busy-start",
829
+ busyState: true,
830
+ });
831
+
832
+ const timer = setInterval(() => {
833
+ refreshTerminalTitle({
834
+ terminalTitleMode,
835
+ workspaceName: deriveTerminalTitle(workspaceRoot, "dir"),
836
+ force: true,
837
+ debugEventName: "busy-guard",
838
+ busyState: true,
839
+ });
840
+ }, 250);
841
+
842
+ return () => {
843
+ clearInterval(timer);
844
+ };
845
+ }, [uiState.kind, busy, terminalTitleMode, workspaceRoot]);
846
+
847
+ useEffect(() => {
848
+ setIntendedTerminalTitle(terminalTitleLabel, { force: true, reason: "terminal-title-label-change" });
849
+ }, [terminalTitleLabel]);
850
+ const modelCapabilitiesBusyRef = useRef(modelCapabilitiesBusy);
851
+ modelCapabilitiesBusyRef.current = modelCapabilitiesBusy;
852
+ const composerRows = useMemo(() => {
853
+ if (planFlow.kind === "awaiting_action") {
854
+ return hasVisibleTranscriptPlan ? measurePlanActionPickerRows(terminalLayout.cols) : 1;
855
+ }
856
+ if (planFlow.kind === "collecting_feedback") {
857
+ return measureTextEntryPanelRows();
858
+ }
859
+ return measureBottomComposerRows({
860
+ layout: terminalLayout,
861
+ uiState,
862
+ mode,
863
+ model,
864
+ reasoningLevel,
865
+ tokensUsed: estimateTokens(conversationChars),
866
+ modelSpec: currentModelSpec,
867
+ value: inputValue,
868
+ cursor,
869
+ });
870
+ }, [
871
+ conversationChars,
872
+ currentModelSpec,
873
+ cursor,
874
+ inputValue,
875
+ mode,
876
+ model,
877
+ planFlow.kind,
878
+ hasVisibleTranscriptPlan,
879
+ reasoningLevel,
880
+ terminalLayout,
881
+ uiState,
882
+ ]);
883
+
884
+ renderDebug.useRenderDebug("Root", {
885
+ screen,
886
+ uiStateKind: uiState.kind,
887
+ staticEvents,
888
+ activeEvents,
889
+ activeEventsLength: activeEvents.length,
890
+ inputValue,
891
+ cursor,
892
+ busy,
893
+ composerRows,
894
+ cols: terminalLayout.cols,
895
+ rows: terminalLayout.rows,
896
+ layoutEpoch: terminalLayout.layoutEpoch,
897
+ planFlowKind: planFlow.kind,
898
+ mode,
899
+ model,
900
+ reasoningLevel,
901
+ });
902
+ renderDebug.useLifecycleDebug("App", {
903
+ screen,
904
+ uiStateKind: uiState.kind,
905
+ });
906
+ renderDebug.traceLayoutValidity("Root", {
907
+ cols: terminalLayout.cols,
908
+ rows: terminalLayout.rows,
909
+ rawCols: terminalLayout.rawCols,
910
+ rawRows: terminalLayout.rawRows,
911
+ composerRows,
912
+ });
913
+ const previousUiStateKindRef = useRef(uiState.kind);
914
+ useEffect(() => {
915
+ setTerminalControlUIState(uiState.kind);
916
+ setTerminalTitleLifecycleState(`${uiState.kind}${busy ? ":busy" : ":idle"}`);
917
+ }, [busy, uiState.kind]);
918
+
919
+ useEffect(() => {
920
+ const previousKind = previousUiStateKindRef.current;
921
+ if (previousKind !== uiState.kind) {
922
+ renderDebug.traceStateTransition({
923
+ component: "App",
924
+ prevKind: previousKind,
925
+ nextKind: uiState.kind,
926
+ activeEventsLength: activeEvents.length,
927
+ staticEventsLength: staticEvents.length,
928
+ screen,
929
+ });
930
+ previousUiStateKindRef.current = uiState.kind;
931
+ }
932
+ }, [activeEvents.length, screen, staticEvents.length, uiState.kind]);
933
+ const previousEventCountRef = useRef(staticEvents.length + activeEvents.length);
934
+ useEffect(() => {
935
+ const previousCount = previousEventCountRef.current;
936
+ const nextCount = staticEvents.length + activeEvents.length;
937
+ if (previousCount > 0 && nextCount === 0) {
938
+ renderDebug.traceBlankFrame("Root", {
939
+ reason: "event-count-dropped-to-zero",
940
+ previousCount,
941
+ staticEventsLength: staticEvents.length,
942
+ activeEventsLength: activeEvents.length,
943
+ uiStateKind: uiState.kind,
944
+ screen,
945
+ });
946
+ }
947
+ previousEventCountRef.current = nextCount;
948
+ }, [activeEvents.length, screen, staticEvents.length, uiState.kind]);
949
+ const previousRootMeasurements = useRef<{
950
+ composerRows: number;
951
+ cols: number;
952
+ rows: number;
953
+ layoutEpoch: number;
954
+ } | null>(null);
955
+ useEffect(() => {
956
+ const previous = previousRootMeasurements.current;
957
+ const changed: string[] = [];
958
+ if (!previous) {
959
+ changed.push("mount");
960
+ } else {
961
+ if (previous.composerRows !== composerRows) changed.push("composerRows");
962
+ if (previous.cols !== terminalLayout.cols) changed.push("width");
963
+ if (previous.rows !== terminalLayout.rows) changed.push("height");
964
+ if (previous.layoutEpoch !== terminalLayout.layoutEpoch) changed.push("layoutEpoch");
965
+ }
966
+ if (changed.length > 0) {
967
+ renderDebug.traceEvent("layout", "rootMeasurementUpdate", {
968
+ reason: changed.join(","),
969
+ composerRows,
970
+ cols: terminalLayout.cols,
971
+ rows: terminalLayout.rows,
972
+ layoutEpoch: terminalLayout.layoutEpoch,
973
+ });
974
+ }
975
+ previousRootMeasurements.current = {
976
+ composerRows,
977
+ cols: terminalLayout.cols,
978
+ rows: terminalLayout.rows,
979
+ layoutEpoch: terminalLayout.layoutEpoch,
980
+ };
981
+ }, [composerRows, terminalLayout.cols, terminalLayout.layoutEpoch, terminalLayout.rows]);
982
+
983
+ const backendProvider: BackendProvider = useMemo(() => getBackendProvider(backend), [backend]);
984
+ const provider: BackendProvider = useMemo(() => {
985
+ if (activeProviderRoute.providerId === "openai") {
986
+ return backendProvider;
987
+ }
988
+
989
+ const routeRuntime = getProviderRuntime(activeProviderRoute.providerId);
990
+ return {
991
+ id: backend,
992
+ label: routeRuntime.label,
993
+ description: routeRuntime.routeStatus,
994
+ authState: routeRuntime.routeAvailable ? "delegated" : "coming-soon",
995
+ authLabel: routeRuntime.routeAvailable ? "Configured" : "Not configured",
996
+ statusMessage: routeRuntime.routeStatus,
997
+ supportsModels: (candidateModel) => candidateModel === activeProviderRoute.modelId,
998
+ run: routeRuntime.run
999
+ ? (prompt, options, handlers) => {
1000
+ const geminiCommandPath = activeProviderRoute.providerId === "google"
1001
+ ? providerWorkspaceConfig.providers?.google?.geminiCommandPath ?? options.runtime.geminiCommandPath
1002
+ : options.runtime.geminiCommandPath;
1003
+ return routeRuntime.run?.({
1004
+ prompt,
1005
+ route: activeProviderRoute,
1006
+ runtime: geminiCommandPath ? { ...options.runtime, geminiCommandPath } : options.runtime,
1007
+ workspaceRoot: options.workspaceRoot,
1008
+ projectInstructions: options.projectInstructions,
1009
+ localConfig: activeProviderRoute.providerId === "local"
1010
+ ? providerWorkspaceConfig.providers?.local
1011
+ : undefined,
1012
+ }, handlers) ?? (() => undefined);
1013
+ }
1014
+ : undefined,
1015
+ };
1016
+ }, [activeProviderRoute, backend, backendProvider, providerWorkspaceConfig.providers]);
1017
+
1018
+ const getInputDebugSnapshot = useCallback((extra: Record<string, unknown> = {}) => {
1019
+ const currentScreen = screenRef.current;
1020
+ const currentBusy = busyRef.current;
1021
+ const currentModelLoading = modelCapabilitiesBusyRef.current;
1022
+
1023
+ return {
1024
+ screen: currentScreen,
1025
+ mode: intendedInputModeRef.current,
1026
+ modelPickerOpen: currentScreen === "model-picker",
1027
+ composerEnabled: currentScreen === "main" && !currentBusy,
1028
+ inputLocked: currentBusy,
1029
+ busy: currentBusy,
1030
+ modelLoading: currentModelLoading,
1031
+ modelSelection: modelSelectionInFlightRef.current,
1032
+ focusTarget: intendedFocusTargetRef.current,
1033
+ stdin: getStdinDebugState(stdin),
1034
+ ...extra,
1035
+ };
1036
+ }, [stdin]);
1037
+
1038
+ useEffect(() => {
1039
+ baseRuntimeConfigRef.current = baseLayeredConfig.runtime;
1040
+ }, [baseLayeredConfig.runtime]);
1041
+
1042
+ useEffect(() => {
1043
+ saveSettings({
1044
+ ui: {
1045
+ layoutStyle: initialSettings.current.ui.layoutStyle,
1046
+ theme: themeSelection.committedTheme,
1047
+ workspaceDisplayMode,
1048
+ terminalTitleMode,
1049
+ showBusyLoader,
1050
+ terminalMouseMode,
1051
+ customTheme,
1052
+ },
1053
+ auth: {
1054
+ preference: authPreference,
1055
+ },
1056
+ header: headerConfig,
1057
+ });
1058
+ }, [authPreference, customTheme, showBusyLoader, terminalMouseMode, terminalTitleMode, themeSelection.committedTheme, workspaceDisplayMode]);
1059
+
1060
+ useEffect(() => {
1061
+ return () => {
1062
+ isMountedRef.current = false;
1063
+ cleanupRef.current?.();
1064
+ if (themePreviewTimerRef.current) {
1065
+ clearTimeout(themePreviewTimerRef.current);
1066
+ themePreviewTimerRef.current = null;
1067
+ }
1068
+ };
1069
+ }, []);
1070
+
1071
+ useEffect(() => {
1072
+ if (screen === "theme-picker") {
1073
+ return;
1074
+ }
1075
+
1076
+ if (themePreviewTimerRef.current) {
1077
+ clearTimeout(themePreviewTimerRef.current);
1078
+ themePreviewTimerRef.current = null;
1079
+ }
1080
+ }, [screen]);
1081
+
1082
+ useEffect(() => {
1083
+ const previousScreen = previousScreenRef.current;
1084
+ if (shouldBumpComposerInstance(previousScreen, screen)) {
1085
+ setComposerInstanceKey((currentKey) => currentKey + 1);
1086
+ }
1087
+ previousScreenRef.current = screen;
1088
+ }, [screen]);
1089
+
1090
+ useEffect(() => {
1091
+ const focusTarget = getFocusTargetForScreen(screen);
1092
+ intendedFocusTargetRef.current = focusTarget;
1093
+ traceInputDebug("focus_route", getInputDebugSnapshot({ focusTarget }));
1094
+ focusManager.focus(focusTarget);
1095
+ }, [composerInstanceKey, focusManager, getInputDebugSnapshot, screen]);
1096
+
1097
+ useEffect(() => {
1098
+ if (screen !== "main") return;
1099
+
1100
+ if (planFlow.kind === "awaiting_action") {
1101
+ intendedFocusTargetRef.current = FOCUS_IDS.composer;
1102
+ focusManager.focus(FOCUS_IDS.composer);
1103
+ return;
1104
+ }
1105
+
1106
+ if (planFlow.kind === "collecting_feedback") {
1107
+ intendedFocusTargetRef.current = FOCUS_IDS.composer;
1108
+ focusManager.focus(FOCUS_IDS.composer);
1109
+ }
1110
+ }, [focusManager, planFlow.kind, screen]);
1111
+
1112
+ useEffect(() => {
1113
+ if (screen === "model-picker" || intendedInputModeRef.current !== "model-picker") {
1114
+ return;
1115
+ }
1116
+
1117
+ traceInputDebug("safety_recovery", getInputDebugSnapshot({
1118
+ reason: "model-picker-closed-with-stale-input-mode",
1119
+ restoredMode: "chat/input",
1120
+ restoredFocusTarget: FOCUS_IDS.composer,
1121
+ }));
1122
+ intendedInputModeRef.current = "chat/input";
1123
+ intendedFocusTargetRef.current = FOCUS_IDS.composer;
1124
+ focusManager.focus(FOCUS_IDS.composer);
1125
+ }, [focusManager, getInputDebugSnapshot, screen]);
1126
+
1127
+ const returnToChatMode = useCallback((reason = "unknown") => {
1128
+ intendedInputModeRef.current = "chat/input";
1129
+ intendedFocusTargetRef.current = FOCUS_IDS.composer;
1130
+ traceInputDebug("model_picker_close", getInputDebugSnapshot({
1131
+ reason,
1132
+ restoredMode: "chat/input",
1133
+ restoredModelPickerOpen: false,
1134
+ restoredComposerEnabled: true,
1135
+ restoredInputLocked: false,
1136
+ restoredFocusTarget: FOCUS_IDS.composer,
1137
+ }));
1138
+ setScreen("main");
1139
+ focusManager.focus(FOCUS_IDS.composer);
1140
+ }, [focusManager, getInputDebugSnapshot]);
1141
+
1142
+ const appendStaticEvent = useCallback((event: TimelineEvent) => {
1143
+ dispatchSession({ type: "APPEND_STATIC_EVENT", event });
1144
+ }, [dispatchSession]);
1145
+
1146
+ const appendSystemEvent = useCallback((title: string, content: string) => {
1147
+ const safeTitle = sanitizeTerminalOutput(title);
1148
+ const safeContent = sanitizeTerminalOutput(content, { preserveTabs: false, tabSize: 2 });
1149
+ appendStaticEvent({
1150
+ id: createEventId(),
1151
+ type: "system",
1152
+ createdAt: Date.now(),
1153
+ title: safeTitle,
1154
+ content: safeContent,
1155
+ });
1156
+ }, [appendStaticEvent]);
1157
+
1158
+ const appendErrorEvent = useCallback((title: string, content: string) => {
1159
+ const safeTitle = sanitizeTerminalOutput(title);
1160
+ const safeContent = sanitizeTerminalOutput(content, { preserveTabs: false, tabSize: 2 });
1161
+ appendStaticEvent({
1162
+ id: createEventId(),
1163
+ type: "error",
1164
+ createdAt: Date.now(),
1165
+ title: safeTitle,
1166
+ content: safeContent,
1167
+ });
1168
+ }, [appendStaticEvent]);
1169
+
1170
+ useEffect(() => {
1171
+ const notice = providerWorkspaceConfig.migrationNotice;
1172
+ if (!notice || providerMigrationNoticeShownRef.current) return;
1173
+ providerMigrationNoticeShownRef.current = true;
1174
+ const providerLabel = findProvider(providerRegistry, notice.revertedProviderId)?.displayName ?? "OpenAI";
1175
+ appendSystemEvent(
1176
+ "Provider migrated",
1177
+ `Antigravity provider is no longer supported. Reverted to ${providerLabel}.`,
1178
+ );
1179
+ }, [appendSystemEvent, providerRegistry, providerWorkspaceConfig.migrationNotice]);
1180
+
1181
+ useEffect(() => {
1182
+ if (projectInstructionsLoad.status === "loaded") {
1183
+ traceInputDebug("project_instructions_loaded", {
1184
+ path: projectInstructionsLoad.instructions.path,
1185
+ content: projectInstructionsLoad.instructions.content,
1186
+ });
1187
+ return;
1188
+ }
1189
+
1190
+ if (projectInstructionsLoad.status === "error") {
1191
+ appendErrorEvent(
1192
+ "Project instructions",
1193
+ `Could not read ${projectInstructionsLoad.path}: ${projectInstructionsLoad.message}`,
1194
+ );
1195
+ }
1196
+ }, [appendErrorEvent, projectInstructionsLoad]);
1197
+
1198
+ const refreshModelCapabilities = useCallback((forceRefresh = false, announce = false): Promise<CodexModelCapabilities> => {
1199
+ // Single-flight: concurrent requests share the same in-flight discovery
1200
+ // promise so we never spawn a duplicate discovery job or emit duplicate
1201
+ // transcript messages.
1202
+ if (modelDiscoveryInFlightRef.current && !forceRefresh) {
1203
+ traceInputDebug("model_loading_inflight", getInputDebugSnapshot({ forceRefresh, announce }));
1204
+ if (announce) {
1205
+ modelDiscoveryAnnounceRef.current = true;
1206
+ }
1207
+ return modelDiscoveryInFlightRef.current;
1208
+ }
1209
+
1210
+ if (announce) {
1211
+ modelDiscoveryAnnounceRef.current = true;
1212
+ }
1213
+
1214
+ setModelCapabilitiesBusy(true);
1215
+ traceInputDebug("model_loading_start", getInputDebugSnapshot({ forceRefresh, announce }));
1216
+ const promise = (async () => {
1217
+ try {
1218
+ const capabilities = await getCodexModelCapabilities({ forceRefresh });
1219
+ setModelCapabilities(capabilities);
1220
+ traceInputDebug("model_loading_success", getInputDebugSnapshot({
1221
+ status: capabilities.status,
1222
+ source: capabilities.source,
1223
+ modelCount: getSelectableModelCapabilities(capabilities).length,
1224
+ }));
1225
+ if (capabilities.status === "fallback") {
1226
+ traceInputDebug("model_loading_failure", getInputDebugSnapshot({
1227
+ status: capabilities.status,
1228
+ error: capabilities.error,
1229
+ }));
1230
+ }
1231
+ if (modelDiscoveryAnnounceRef.current) {
1232
+ const modelCount = getSelectableModelCapabilities(capabilities).length;
1233
+ const source = capabilities.status === "ready" ? "Codex runtime" : "fallback compatibility list";
1234
+ appendSystemEvent("Model discovery", `Loaded ${modelCount} models from ${source}.`);
1235
+ }
1236
+ return capabilities;
1237
+ } catch (error) {
1238
+ const fallback = createFallbackModelCapabilities(error);
1239
+ setModelCapabilities(fallback);
1240
+ traceInputDebug("model_loading_failure", getInputDebugSnapshot({
1241
+ error: error instanceof Error ? error.message : String(error),
1242
+ }));
1243
+ if (modelDiscoveryAnnounceRef.current) {
1244
+ appendErrorEvent("Model discovery failed", fallback.error ?? "Unable to discover Codex models.");
1245
+ }
1246
+ return fallback;
1247
+ } finally {
1248
+ setModelCapabilitiesBusy(false);
1249
+ modelDiscoveryInFlightRef.current = null;
1250
+ modelDiscoveryAnnounceRef.current = false;
1251
+ traceInputDebug("model_loading_finished", getInputDebugSnapshot({ forceRefresh, announce }));
1252
+ }
1253
+ })();
1254
+
1255
+ modelDiscoveryInFlightRef.current = promise;
1256
+ return promise;
1257
+ }, [appendErrorEvent, appendSystemEvent, getInputDebugSnapshot]);
1258
+
1259
+ const setRuntimeUnauthenticated = useCallback((summary: string) => {
1260
+ setAuthStatus({
1261
+ state: "unauthenticated",
1262
+ checkedAt: Date.now(),
1263
+ rawSummary: summary,
1264
+ recommendedAction: "Run `codex login` and retry.",
1265
+ });
1266
+ }, []);
1267
+
1268
+ const refreshAuthStatus = useCallback(async (announce: boolean) => {
1269
+ setAuthStatusBusy(true);
1270
+ setAuthStatus((prev) => ({ ...prev, state: "checking" }));
1271
+
1272
+ try {
1273
+ const result = await probeCodexAuthStatus();
1274
+ setAuthStatus(result);
1275
+ if (announce) {
1276
+ appendSystemEvent("Auth status", getAuthStatusMessage(result));
1277
+ }
1278
+ } catch (error) {
1279
+ const message = error instanceof Error ? error.message : "Unknown auth probe failure";
1280
+ const fallback: CodexAuthProbeResult = {
1281
+ state: "unknown",
1282
+ checkedAt: Date.now(),
1283
+ rawSummary: message,
1284
+ recommendedAction: "Run `codex login` manually, then retry /auth status.",
1285
+ };
1286
+ setAuthStatus(fallback);
1287
+ if (announce) {
1288
+ appendErrorEvent("Auth status probe failed", message);
1289
+ }
1290
+ } finally {
1291
+ setAuthStatusBusy(false);
1292
+ }
1293
+ }, [appendErrorEvent, appendSystemEvent]);
1294
+
1295
+ useEffect(() => {
1296
+ void refreshAuthStatus(false);
1297
+ }, []);
1298
+
1299
+ // Probe Anthropic/Claude Code CLI auth at startup so the provider picker
1300
+ // shows the correct route availability without requiring manual activation.
1301
+ useEffect(() => {
1302
+ void (async () => {
1303
+ try {
1304
+ const result = await validateAnthropicRoute({ cwd: workspaceRoot, configuredPath: providerWorkspaceConfig.providers?.anthropic?.claudeCommandPath });
1305
+ if (result.diagnostics) {
1306
+ providerDiagnosticsRef.current["anthropic"] = result.diagnostics as Record<string, string | number | boolean | null>;
1307
+ }
1308
+ if (result.status !== "ready") {
1309
+ providerRouteErrorsRef.current["anthropic"] = result.message ?? ANTHROPIC_ROUTE_SETUP_MESSAGE;
1310
+ } else {
1311
+ delete providerRouteErrorsRef.current["anthropic"];
1312
+ }
1313
+ } catch {
1314
+ // Best-effort probe — failures are surfaced only when the user activates the route.
1315
+ }
1316
+ setRegistryNonce((n) => n + 1);
1317
+ })();
1318
+ // workspaceRoot is stable for the session lifetime; this runs exactly once.
1319
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1320
+ }, [workspaceRoot]);
1321
+
1322
+ // Probe local OpenAI-compatible servers such as LM Studio at startup so the
1323
+ // provider picker can show actual availability and loaded model IDs.
1324
+ useEffect(() => {
1325
+ void (async () => {
1326
+ try {
1327
+ const result = await checkLocalProvider({ override: providerWorkspaceConfig.providers?.local });
1328
+ if (result.diagnostics) {
1329
+ providerDiagnosticsRef.current["local"] = result.diagnostics as Record<string, string | number | boolean | null>;
1330
+ }
1331
+ if (result.status !== "ready") {
1332
+ providerRouteErrorsRef.current["local"] = result.message ?? "Local provider unavailable.";
1333
+ } else {
1334
+ delete providerRouteErrorsRef.current["local"];
1335
+ }
1336
+ } catch {
1337
+ // Best-effort probe — failures are surfaced only when the user activates the route.
1338
+ }
1339
+ setRegistryNonce((n) => n + 1);
1340
+ })();
1341
+ // workspaceRoot is stable for the session lifetime; local provider config is
1342
+ // loaded before this first startup probe.
1343
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1344
+ }, [workspaceRoot]);
1345
+
1346
+ useEffect(() => {
1347
+ const devLaunchNotice = buildDevLaunchNotice(launchContext);
1348
+ if (!devLaunchNotice) return;
1349
+
1350
+ appendSystemEvent("Launch mode", devLaunchNotice);
1351
+ }, [appendSystemEvent, launchContext]);
1352
+
1353
+ // Track clear epoch to suppress stale command result events
1354
+ useEffect(() => {
1355
+ clearEpochRef.current = sessionState.clearEpoch;
1356
+ }, [sessionState.clearEpoch]);
1357
+
1358
+ useEffect(() => {
1359
+ externalCliStatusRef.current = sessionState.externalCliStatus;
1360
+ }, [sessionState.externalCliStatus]);
1361
+
1362
+ const reloadBaseLayeredConfig = useCallback(() => {
1363
+ const nextConfig = resolveLayeredConfig({ workspaceRoot, launchArgs });
1364
+ baseRuntimeConfigRef.current = nextConfig.runtime;
1365
+ setBaseLayeredConfig(nextConfig);
1366
+ return nextConfig;
1367
+ }, [launchArgs, workspaceRoot]);
1368
+
1369
+ const updateRuntimeConfig = useCallback((updater: (current: RuntimeConfig) => RuntimeConfig) => {
1370
+ setSessionRuntimeOverride((currentPatch) => {
1371
+ const baseRuntime = baseRuntimeConfigRef.current;
1372
+ const currentRuntime = mergeRuntimeConfig(baseRuntime, currentPatch);
1373
+ const nextRuntime = updater(currentRuntime);
1374
+ return diffRuntimeConfig(baseRuntime, nextRuntime);
1375
+ });
1376
+ }, []);
1377
+
1378
+ const updateRuntimePolicy = useCallback((updater: (current: RuntimeConfig["policy"]) => RuntimeConfig["policy"]) => {
1379
+ updateRuntimeConfig((current) => ({
1380
+ ...current,
1381
+ policy: updater(current.policy),
1382
+ }));
1383
+ }, [updateRuntimeConfig]);
1384
+
1385
+ const persistActiveRoute = useCallback((
1386
+ providerId: ProviderId,
1387
+ nextModel: string,
1388
+ nextReasoning: string,
1389
+ backendKindOverride?: ReturnType<typeof getProviderRuntime>["backendKind"],
1390
+ modelSelection?: import("./core/providerRuntime/types.js").GeminiModelSelection,
1391
+ ) => {
1392
+ try {
1393
+ const runtime = getProviderRuntime(providerId);
1394
+ let nextConfig = setProviderActiveRoute(providerWorkspaceConfig, {
1395
+ providerId,
1396
+ modelId: nextModel,
1397
+ backendKind: backendKindOverride ?? runtime.backendKind,
1398
+ reasoning: nextReasoning,
1399
+ modelSelection,
1400
+ });
1401
+ nextConfig = setProviderDefaultReasoning(
1402
+ setProviderDefaultModel(nextConfig, providerId, nextModel),
1403
+ providerId,
1404
+ nextReasoning,
1405
+ );
1406
+ saveProviderWorkspaceConfig(workspaceRoot, nextConfig);
1407
+ setProviderWorkspaceConfig(nextConfig);
1408
+ } catch (error) {
1409
+ const message = error instanceof Error ? error.message : "Unable to save active route.";
1410
+ appendErrorEvent("Route save failed", message);
1411
+ }
1412
+ }, [appendErrorEvent, providerWorkspaceConfig, workspaceRoot]);
1413
+
1414
+ const persistProviderDefaultModelAndReasoning = useCallback((
1415
+ providerId: ProviderId,
1416
+ modelId: string,
1417
+ nextReasoning: string,
1418
+ ) => {
1419
+ try {
1420
+ const withModel = setProviderDefaultModel(providerWorkspaceConfig, providerId, modelId);
1421
+ const nextConfig = setProviderDefaultReasoning(withModel, providerId, nextReasoning);
1422
+ saveProviderWorkspaceConfig(workspaceRoot, nextConfig);
1423
+ setProviderWorkspaceConfig(nextConfig);
1424
+ } catch (error) {
1425
+ const message = error instanceof Error ? error.message : "Unable to save provider defaults.";
1426
+ appendErrorEvent("Provider defaults save failed", message);
1427
+ }
1428
+ }, [appendErrorEvent, providerWorkspaceConfig, workspaceRoot]);
1429
+
1430
+ // Auto-correct the runtime model when capabilities load and the configured model is
1431
+ // unavailable. Placed after persistActiveRoute / persistProviderDefaultModelAndReasoning
1432
+ // declarations because the effect calls persistActiveRoute (TDZ-safe from here).
1433
+ useEffect(() => {
1434
+ if (activeProviderRoute.providerId !== "openai") {
1435
+ return;
1436
+ }
1437
+ if (modelCapabilities?.status !== "ready") {
1438
+ return;
1439
+ }
1440
+
1441
+ const nextModel = getPreferredModelFromCapabilities(modelCapabilities, model);
1442
+ const nextReasoning = normalizeReasoningForModelCapabilities(
1443
+ nextModel,
1444
+ reasoningLevel,
1445
+ modelCapabilities,
1446
+ );
1447
+
1448
+ if (nextModel === model && nextReasoning === reasoningLevel) {
1449
+ return;
1450
+ }
1451
+
1452
+ updateRuntimeConfig((current) => ({
1453
+ ...current,
1454
+ model: nextModel,
1455
+ reasoningLevel: nextReasoning,
1456
+ }));
1457
+
1458
+ // Persist the corrected model so providers.json stays in sync and the same
1459
+ // correction does not silently re-fire on every restart. Skip persistence
1460
+ // when --model was given on the CLI: that session is intentionally temporary.
1461
+ if (nextModel !== model && !launchArgs.modelOverride) {
1462
+ persistActiveRoute(activeProviderRoute.providerId, nextModel, nextReasoning);
1463
+ }
1464
+
1465
+ if (nextModel !== model) {
1466
+ appendSystemEvent(
1467
+ "Model updated",
1468
+ `Configured model ${model} is unavailable in the detected Codex runtime. Active model is now ${nextModel}.`,
1469
+ );
1470
+ } else if (nextReasoning !== reasoningLevel) {
1471
+ appendSystemEvent(
1472
+ "Reasoning updated",
1473
+ `Reasoning level is now ${formatReasoningLabel(nextReasoning)} for ${nextModel}.`,
1474
+ );
1475
+ }
1476
+ }, [activeProviderRoute.providerId, appendSystemEvent, launchArgs.modelOverride, model, modelCapabilities, persistActiveRoute, reasoningLevel, updateRuntimeConfig]);
1477
+
1478
+ const setBackendWithNotice = useCallback((nextBackend: AvailableBackend) => {
1479
+ const gate = guardConfigMutation("backend", busy);
1480
+ if (!gate.allowed) {
1481
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before changing the backend.");
1482
+ return;
1483
+ }
1484
+
1485
+ updateRuntimeConfig((current) => ({
1486
+ ...current,
1487
+ provider: nextBackend,
1488
+ }));
1489
+ setScreen("main");
1490
+ appendSystemEvent("Backend updated", `Active backend is now ${formatBackendLabel(nextBackend)}.`);
1491
+ if (nextBackend === "codex-subprocess") {
1492
+ void refreshAuthStatus(false);
1493
+ }
1494
+ }, [appendSystemEvent, busy, refreshAuthStatus, updateRuntimeConfig]);
1495
+
1496
+ const setModeWithNotice = useCallback((nextMode: AvailableMode) => {
1497
+ const gate = guardConfigMutation("mode", busy);
1498
+ if (!gate.allowed) {
1499
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before changing the mode.");
1500
+ return;
1501
+ }
1502
+
1503
+ updateRuntimeConfig((current) => ({
1504
+ ...current,
1505
+ mode: nextMode,
1506
+ }));
1507
+ setScreen("main");
1508
+ appendSystemEvent("Mode updated", `Execution mode switched to ${formatModeLabel(nextMode)}.`);
1509
+ }, [appendSystemEvent, busy, updateRuntimeConfig]);
1510
+
1511
+ const cycleModeWithNotice = useCallback(() => {
1512
+ setModeWithNotice(getNextMode(mode));
1513
+ }, [mode, setModeWithNotice]);
1514
+
1515
+ const setReasoningWithNotice = useCallback((nextReasoningLevel: ReasoningLevel) => {
1516
+ const gate = guardConfigMutation("reasoning", busy);
1517
+ if (!gate.allowed) {
1518
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before changing the reasoning level.");
1519
+ return;
1520
+ }
1521
+
1522
+ const supported = currentModelCapability?.supportedReasoningLevels;
1523
+ if (supported && !supported.some((item) => item.id === nextReasoningLevel)) {
1524
+ appendErrorEvent(
1525
+ "Reasoning unavailable",
1526
+ `${model} does not advertise ${formatReasoningLabel(nextReasoningLevel)} reasoning in the detected Codex runtime.`,
1527
+ );
1528
+ return;
1529
+ }
1530
+
1531
+ updateRuntimeConfig((current) => ({
1532
+ ...current,
1533
+ reasoningLevel: nextReasoningLevel,
1534
+ }));
1535
+ // When --model was given on the CLI the active route's modelId reflects that CLI arg,
1536
+ // not what is stored in providers.json. Persist the stored model so the CLI-only
1537
+ // override is never written permanently; fall back to activeProviderRoute.modelId when
1538
+ // no CLI model is active (normal interactive case).
1539
+ const modelToPersist = launchArgs.modelOverride
1540
+ ? (providerWorkspaceConfig.activeRoute?.modelId ?? activeProviderRoute.modelId)
1541
+ : activeProviderRoute.modelId;
1542
+ if (activeProviderRoute.providerId === "openai") {
1543
+ persistProviderDefaultModelAndReasoning("openai", modelToPersist, nextReasoningLevel);
1544
+ } else {
1545
+ persistActiveRoute(
1546
+ activeProviderRoute.providerId,
1547
+ modelToPersist,
1548
+ nextReasoningLevel,
1549
+ activeProviderRoute.backendKind,
1550
+ activeProviderRoute.modelSelection,
1551
+ );
1552
+ }
1553
+ setScreen("main");
1554
+ appendSystemEvent("Reasoning updated", `Reasoning level is now ${formatReasoningLabel(nextReasoningLevel)}.`);
1555
+ refreshTerminalTitle({
1556
+ terminalTitleMode,
1557
+ workspaceName: deriveTerminalTitle(workspaceRoot, "dir"),
1558
+ force: true,
1559
+ });
1560
+ }, [
1561
+ activeProviderRoute.backendKind,
1562
+ activeProviderRoute.modelId,
1563
+ activeProviderRoute.modelSelection,
1564
+ activeProviderRoute.providerId,
1565
+ appendErrorEvent,
1566
+ appendSystemEvent,
1567
+ busy,
1568
+ currentModelCapability,
1569
+ launchArgs.modelOverride,
1570
+ model,
1571
+ persistActiveRoute,
1572
+ persistProviderDefaultModelAndReasoning,
1573
+ providerWorkspaceConfig.activeRoute,
1574
+ terminalTitleMode,
1575
+ updateRuntimeConfig,
1576
+ workspaceRoot,
1577
+ ]);
1578
+
1579
+ const setPlanModeWithNotice = useCallback((nextEnabled: boolean) => {
1580
+ const gate = guardConfigMutation("mode", busy);
1581
+ if (!gate.allowed) {
1582
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before changing plan mode.");
1583
+ return;
1584
+ }
1585
+
1586
+ updateRuntimeConfig((current) => ({
1587
+ ...current,
1588
+ planMode: nextEnabled,
1589
+ }));
1590
+ if (!nextEnabled) {
1591
+ setPlanFlow(resetPlanFlow());
1592
+ }
1593
+ appendSystemEvent("Plan mode", `Plan mode ${nextEnabled ? "enabled" : "disabled"}.`);
1594
+ }, [appendSystemEvent, busy, updateRuntimeConfig]);
1595
+
1596
+ const togglePlanModeWithNotice = useCallback(() => {
1597
+ setPlanModeWithNotice(!planMode);
1598
+ }, [planMode, setPlanModeWithNotice]);
1599
+
1600
+ const setModelWithNotice = useCallback(async (nextModel: AvailableModel) => {
1601
+ const gate = guardConfigMutation("model", busy);
1602
+ if (!gate.allowed) {
1603
+ traceInputDebug("model_selection_blocked", getInputDebugSnapshot({
1604
+ handler: "setModelWithNotice",
1605
+ model: nextModel,
1606
+ reason: "busy",
1607
+ }));
1608
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before changing the model.");
1609
+ return;
1610
+ }
1611
+
1612
+ modelSelectionInFlightRef.current = true;
1613
+ traceInputDebug("model_selection_app_start", getInputDebugSnapshot({
1614
+ handler: "setModelWithNotice",
1615
+ model: nextModel,
1616
+ }));
1617
+
1618
+ try {
1619
+ const routeProviderId = activeProviderRoute.providerId;
1620
+ const normalizedReasoning = normalizeReasoningForModelCapabilities(nextModel, reasoningLevel, activeRouteModelCapabilities);
1621
+ const validation = await validateProviderRouteActivation({
1622
+ route: {
1623
+ providerId: routeProviderId,
1624
+ modelId: nextModel,
1625
+ backendKind: getProviderRuntime(routeProviderId).backendKind,
1626
+ reasoning: normalizedReasoning,
1627
+ },
1628
+ workspaceRoot,
1629
+ geminiCommandPath: providerWorkspaceConfig.providers?.google?.geminiCommandPath ?? runtimeConfig.geminiCommandPath,
1630
+ claudeCommandPath: providerWorkspaceConfig.providers?.anthropic?.claudeCommandPath,
1631
+ localConfig: providerWorkspaceConfig.providers?.local,
1632
+ });
1633
+ if (validation.status !== "ready") {
1634
+ appendSystemEvent(
1635
+ "Provider route unavailable",
1636
+ `${validation.message ?? getProviderRouteSetupMessage(routeProviderId)} Previous active route remains ${activeRouteProvider?.displayName ?? "OpenAI"} / ${activeProviderRoute.modelId}.`,
1637
+ );
1638
+ return;
1639
+ }
1640
+ updateRuntimeConfig((current) => ({
1641
+ ...current,
1642
+ model: nextModel,
1643
+ reasoningLevel: normalizeReasoningForModelCapabilities(nextModel, current.reasoningLevel, activeRouteModelCapabilities),
1644
+ }));
1645
+ persistActiveRoute(routeProviderId, nextModel, normalizedReasoning, validation.backendKind);
1646
+ traceInputDebug("model_selection_app_success", getInputDebugSnapshot({
1647
+ handler: "setModelWithNotice",
1648
+ model: nextModel,
1649
+ }));
1650
+ appendSystemEvent(
1651
+ "Model updated",
1652
+ `Active model is now ${nextModel}. Reasoning set to ${formatReasoningLabel(normalizedReasoning)}.`,
1653
+ );
1654
+ refreshTerminalTitle({
1655
+ terminalTitleMode,
1656
+ workspaceName: deriveTerminalTitle(workspaceRoot, "dir"),
1657
+ force: true,
1658
+ });
1659
+ } catch (error) {
1660
+ const message = error instanceof Error ? error.message : String(error);
1661
+ traceInputDebug("model_selection_app_failure", getInputDebugSnapshot({
1662
+ handler: "setModelWithNotice",
1663
+ model: nextModel,
1664
+ error: message,
1665
+ }));
1666
+ appendErrorEvent("Model selection failed", message);
1667
+ } finally {
1668
+ modelSelectionInFlightRef.current = false;
1669
+ returnToChatMode("selection");
1670
+ }
1671
+ }, [activeProviderRoute.modelId, activeProviderRoute.providerId, activeRouteModelCapabilities, activeRouteProvider, appendErrorEvent, appendSystemEvent, busy, getInputDebugSnapshot, persistActiveRoute, providerWorkspaceConfig.providers, reasoningLevel, returnToChatMode, runtimeConfig.geminiCommandPath, updateRuntimeConfig, workspaceRoot]);
1672
+
1673
+ const setModelAndReasoningWithNotice = useCallback(async (
1674
+ nextModel: AvailableModel,
1675
+ nextReasoning: ReasoningLevel,
1676
+ providerId: ProviderId = activeProviderRoute.providerId,
1677
+ geminiSelection?: import("./core/providerRuntime/types.js").GeminiModelSelection,
1678
+ ) => {
1679
+ const gate = guardConfigMutation("model", busy);
1680
+ if (!gate.allowed) {
1681
+ traceInputDebug("model_selection_blocked", getInputDebugSnapshot({
1682
+ handler: "setModelAndReasoningWithNotice",
1683
+ model: nextModel,
1684
+ reasoning: nextReasoning,
1685
+ reason: "busy",
1686
+ }));
1687
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before changing the model.");
1688
+ returnToChatMode("selection-blocked");
1689
+ return;
1690
+ }
1691
+
1692
+ const routeCapabilities = providerId === "openai"
1693
+ ? modelCapabilities
1694
+ : providerModelsToCodexCapabilities(discoverProviderModels(providerId).models, nextModel);
1695
+ const selectedCapability = findModelCapability(routeCapabilities, nextModel);
1696
+ const supported = selectedCapability?.supportedReasoningLevels;
1697
+ if (supported && supported.length > 0 && !supported.some((item) => item.id === nextReasoning)) {
1698
+ appendErrorEvent(
1699
+ "Reasoning unavailable",
1700
+ `${nextModel} does not advertise ${formatReasoningLabel(nextReasoning)} reasoning in the detected Codex runtime.`,
1701
+ );
1702
+ returnToChatMode("selection-invalid");
1703
+ return;
1704
+ }
1705
+
1706
+ modelSelectionInFlightRef.current = true;
1707
+ traceInputDebug("model_selection_app_start", getInputDebugSnapshot({
1708
+ handler: "setModelAndReasoningWithNotice",
1709
+ model: nextModel,
1710
+ reasoning: nextReasoning,
1711
+ }));
1712
+
1713
+ try {
1714
+ const normalizedReasoning = normalizeReasoningForModelCapabilities(nextModel, nextReasoning, routeCapabilities);
1715
+ const releaseTitleGuard = acquireTerminalTitleGuard(500, () => {
1716
+ forceRefreshCurrentTerminalTitle("provider_validation_active", true);
1717
+ });
1718
+ let validation;
1719
+ try {
1720
+ validation = await validateProviderRouteActivation({
1721
+ route: {
1722
+ providerId,
1723
+ modelId: nextModel,
1724
+ backendKind: getProviderRuntime(providerId).backendKind,
1725
+ reasoning: normalizedReasoning,
1726
+ modelSelection: geminiSelection,
1727
+ },
1728
+ workspaceRoot,
1729
+ geminiCommandPath: providerWorkspaceConfig.providers?.google?.geminiCommandPath ?? runtimeConfig.geminiCommandPath,
1730
+ claudeCommandPath: providerWorkspaceConfig.providers?.anthropic?.claudeCommandPath,
1731
+ localConfig: providerWorkspaceConfig.providers?.local,
1732
+ });
1733
+ if (validation.diagnostics) {
1734
+ providerDiagnosticsRef.current[providerId] = validation.diagnostics as Record<string, string | number | boolean | null>;
1735
+ }
1736
+ setRegistryNonce((n) => n + 1);
1737
+ } finally {
1738
+ releaseTitleGuard();
1739
+ forceRefreshCurrentTerminalTitle("after_provider_validation", false);
1740
+ }
1741
+ if (validation.status !== "ready") {
1742
+ traceInputDebug("model_selection_app_failure", getInputDebugSnapshot({
1743
+ handler: "setModelAndReasoningWithNotice",
1744
+ model: nextModel,
1745
+ reasoning: nextReasoning,
1746
+ error: validation.message ?? getProviderRouteSetupMessage(providerId),
1747
+ }));
1748
+ const errorMessage = validation.message ?? getProviderRouteSetupMessage(providerId);
1749
+ if (providerRouteErrorsRef.current[providerId] !== errorMessage) {
1750
+ appendSystemEvent(
1751
+ "Provider route unavailable",
1752
+ `${errorMessage} Previous active route remains ${activeRouteProvider?.displayName ?? "OpenAI"} / ${activeProviderRoute.modelId}.`,
1753
+ );
1754
+ providerRouteErrorsRef.current[providerId] = errorMessage;
1755
+ }
1756
+ setPendingRouteProviderId(null);
1757
+ return;
1758
+ }
1759
+
1760
+ if (providerRouteErrorsRef.current[providerId]) {
1761
+ appendSystemEvent(
1762
+ "Provider route available",
1763
+ validation.message ?? (providerId === "google"
1764
+ ? "Google/Gemini is available via Gemini CLI."
1765
+ : providerId === "anthropic"
1766
+ ? "Anthropic/Claude is available via Claude Code."
1767
+ : `${getProviderRuntime(providerId).label} is available via ${validation.backendKind}.`),
1768
+ );
1769
+ delete providerRouteErrorsRef.current[providerId];
1770
+ }
1771
+
1772
+ updateRuntimeConfig((current) => ({
1773
+ ...current,
1774
+ model: nextModel,
1775
+ reasoningLevel: normalizedReasoning,
1776
+ }));
1777
+ persistActiveRoute(providerId, nextModel, normalizedReasoning, validation.backendKind, geminiSelection);
1778
+ setPendingRouteProviderId(null);
1779
+ traceInputDebug("model_selection_app_success", getInputDebugSnapshot({
1780
+ handler: "setModelAndReasoningWithNotice",
1781
+ model: nextModel,
1782
+ reasoning: normalizedReasoning,
1783
+ }));
1784
+
1785
+ // Route changes are reflected reactively in the BottomComposer metadata row.
1786
+ refreshTerminalTitle({
1787
+ terminalTitleMode,
1788
+ workspaceName: deriveTerminalTitle(workspaceRoot, "dir"),
1789
+ force: true,
1790
+ });
1791
+ } catch (error) {
1792
+ const message = error instanceof Error ? error.message : String(error);
1793
+ traceInputDebug("model_selection_app_failure", getInputDebugSnapshot({
1794
+ handler: "setModelAndReasoningWithNotice",
1795
+ model: nextModel,
1796
+ reasoning: nextReasoning,
1797
+ error: message,
1798
+ }));
1799
+ appendErrorEvent("Model selection failed", message);
1800
+ forceRefreshCurrentTerminalTitle("model_selection_failed", false);
1801
+ } finally {
1802
+ modelSelectionInFlightRef.current = false;
1803
+ returnToChatMode("selection");
1804
+ }
1805
+ }, [activeProviderRoute.modelId, activeProviderRoute.providerId, activeRouteProvider, appendErrorEvent, appendSystemEvent, busy, getInputDebugSnapshot, modelCapabilities, persistActiveRoute, providerWorkspaceConfig.providers, returnToChatMode, runtimeConfig.geminiCommandPath, updateRuntimeConfig, workspaceRoot]);
1806
+
1807
+ const setAuthPreferenceWithNotice = useCallback((nextPreference: AuthPreference) => {
1808
+ setAuthPreference(nextPreference);
1809
+ appendSystemEvent("Auth preference updated", `Preference set to ${formatAuthPreferenceLabel(nextPreference)}.`);
1810
+ }, [appendSystemEvent]);
1811
+
1812
+ const applyWorkspaceDisplayMode = useCallback((nextMode: WorkspaceDisplayMode) => {
1813
+ setWorkspaceDisplayMode(nextMode);
1814
+ }, []);
1815
+
1816
+ const setWorkspaceDisplayModeWithNotice = useCallback((nextMode: WorkspaceDisplayMode) => {
1817
+ applyWorkspaceDisplayMode(nextMode);
1818
+ }, [applyWorkspaceDisplayMode]);
1819
+
1820
+ const setTerminalTitleModeWithNotice = useCallback((nextMode: TerminalTitleMode) => {
1821
+ setTerminalTitleMode(nextMode);
1822
+ appendSystemEvent(
1823
+ "Settings",
1824
+ `Terminal title set to ${formatWorkspaceDisplayModeLabel(nextMode)} (${nextMode}).`,
1825
+ );
1826
+ }, [appendSystemEvent]);
1827
+
1828
+ const saveSettingsFromPanel = useCallback((nextSettings: UserSettingValues) => {
1829
+ if (nextSettings.workspaceDisplayMode !== workspaceDisplayMode) {
1830
+ applyWorkspaceDisplayMode(nextSettings.workspaceDisplayMode);
1831
+ }
1832
+ if (nextSettings.terminalTitleMode !== terminalTitleMode) {
1833
+ setTerminalTitleMode(nextSettings.terminalTitleMode);
1834
+ refreshTerminalTitle({
1835
+ terminalTitleMode: nextSettings.terminalTitleMode,
1836
+ workspaceName: deriveTerminalTitle(workspaceRoot, "dir"),
1837
+ force: true,
1838
+ });
1839
+ }
1840
+ const nextShowBusyLoader = parseBusyLoaderSettingValue(nextSettings.showBusyLoader);
1841
+ if (nextShowBusyLoader !== showBusyLoader) {
1842
+ setShowBusyLoader(nextShowBusyLoader);
1843
+ }
1844
+ if (nextSettings.terminalMouseMode !== terminalMouseMode) {
1845
+ setTerminalMouseMode(nextSettings.terminalMouseMode);
1846
+ setMouseOverride(null); // let the newly persisted mode drive mouseCapture
1847
+ }
1848
+ setScreen("main");
1849
+ }, [applyWorkspaceDisplayMode, showBusyLoader, terminalMouseMode, terminalTitleMode, workspaceDisplayMode]);
1850
+
1851
+ const setApprovalPolicyWithNotice = useCallback((nextValue: RuntimeApprovalPolicy) => {
1852
+ const gate = guardConfigMutation("mode", busy);
1853
+ if (!gate.allowed) {
1854
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before changing runtime policy.");
1855
+ return;
1856
+ }
1857
+
1858
+ updateRuntimePolicy((current) => ({ ...current, approvalPolicy: nextValue }));
1859
+ appendSystemEvent("Runtime policy", `Approval policy set to ${formatApprovalPolicyLabel(nextValue)}.`);
1860
+ }, [appendSystemEvent, busy, updateRuntimePolicy]);
1861
+
1862
+ const setSandboxModeWithNotice = useCallback((nextValue: RuntimeSandboxMode) => {
1863
+ const gate = guardConfigMutation("mode", busy);
1864
+ if (!gate.allowed) {
1865
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before changing runtime policy.");
1866
+ return;
1867
+ }
1868
+
1869
+ updateRuntimePolicy((current) => ({ ...current, sandboxMode: nextValue }));
1870
+ appendSystemEvent("Runtime policy", `Sandbox mode set to ${formatSandboxModeLabel(nextValue)}.`);
1871
+ }, [appendSystemEvent, busy, updateRuntimePolicy]);
1872
+
1873
+ const setNetworkAccessWithNotice = useCallback((nextValue: RuntimeNetworkAccess) => {
1874
+ const gate = guardConfigMutation("mode", busy);
1875
+ if (!gate.allowed) {
1876
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before changing runtime policy.");
1877
+ return;
1878
+ }
1879
+
1880
+ updateRuntimePolicy((current) => ({ ...current, networkAccess: nextValue }));
1881
+ appendSystemEvent("Runtime policy", `Network access set to ${formatNetworkAccessLabel(nextValue)}.`);
1882
+ }, [appendSystemEvent, busy, updateRuntimePolicy]);
1883
+
1884
+ const addWritableRootWithNotice = useCallback((pathValue: string) => {
1885
+ const gate = guardConfigMutation("mode", busy);
1886
+ if (!gate.allowed) {
1887
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before changing runtime policy.");
1888
+ return;
1889
+ }
1890
+
1891
+ const resolvedPath = resolveWritableRootCommandPath(pathValue, workspaceRoot);
1892
+ updateRuntimeConfig((current) => addWritableRoot(current, resolvedPath));
1893
+ appendSystemEvent("Runtime policy", `Writable root added: ${resolvedPath}.`);
1894
+ }, [appendSystemEvent, busy, updateRuntimeConfig, workspaceRoot]);
1895
+
1896
+ const removeWritableRootWithNotice = useCallback((pathValue: string) => {
1897
+ const gate = guardConfigMutation("mode", busy);
1898
+ if (!gate.allowed) {
1899
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before changing runtime policy.");
1900
+ return;
1901
+ }
1902
+
1903
+ const resolvedPath = resolveWritableRootCommandPath(pathValue, workspaceRoot);
1904
+ updateRuntimeConfig((current) => removeWritableRoot(current, resolvedPath));
1905
+ appendSystemEvent("Runtime policy", `Writable root removed: ${resolvedPath}.`);
1906
+ }, [appendSystemEvent, busy, updateRuntimeConfig, workspaceRoot]);
1907
+
1908
+ const clearWritableRootsWithNotice = useCallback(() => {
1909
+ const gate = guardConfigMutation("mode", busy);
1910
+ if (!gate.allowed) {
1911
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before changing runtime policy.");
1912
+ return;
1913
+ }
1914
+
1915
+ updateRuntimeConfig((current) => clearWritableRoots(current));
1916
+ appendSystemEvent("Runtime policy", "Writable roots cleared.");
1917
+ }, [appendSystemEvent, busy, updateRuntimeConfig]);
1918
+
1919
+ const setServiceTierWithNotice = useCallback((nextValue: RuntimeServiceTier) => {
1920
+ const gate = guardConfigMutation("mode", busy);
1921
+ if (!gate.allowed) {
1922
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before changing runtime policy.");
1923
+ return;
1924
+ }
1925
+
1926
+ updateRuntimePolicy((current) => ({ ...current, serviceTier: nextValue }));
1927
+ appendSystemEvent("Runtime policy", `Service tier set to ${formatServiceTierLabel(nextValue)}.`);
1928
+ }, [appendSystemEvent, busy, updateRuntimePolicy]);
1929
+
1930
+ const setPersonalityWithNotice = useCallback((nextValue: RuntimePersonality) => {
1931
+ const gate = guardConfigMutation("mode", busy);
1932
+ if (!gate.allowed) {
1933
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before changing runtime policy.");
1934
+ return;
1935
+ }
1936
+
1937
+ updateRuntimePolicy((current) => ({ ...current, personality: nextValue }));
1938
+ appendSystemEvent("Runtime policy", `Personality set to ${formatPersonalityLabel(nextValue)}.`);
1939
+ }, [appendSystemEvent, busy, updateRuntimePolicy]);
1940
+
1941
+ const setProjectTrustWithNotice = useCallback((trusted: boolean) => {
1942
+ const gate = guardConfigMutation("mode", busy);
1943
+ if (!gate.allowed) {
1944
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before changing project trust.");
1945
+ return;
1946
+ }
1947
+
1948
+ const projectRoot = baseLayeredConfig.diagnostics.projectRoot;
1949
+ setProjectTrust(projectRoot, trusted);
1950
+ reloadBaseLayeredConfig();
1951
+ appendSystemEvent(
1952
+ "Config trust",
1953
+ `${trusted ? "Trusted" : "Untrusted"} project root: ${projectRoot}.`,
1954
+ );
1955
+ }, [appendSystemEvent, baseLayeredConfig.diagnostics.projectRoot, busy, reloadBaseLayeredConfig]);
1956
+
1957
+ const openBackendPicker = useCallback(() => {
1958
+ const gate = guardConfigMutation("backend", busy);
1959
+ if (!gate.allowed) {
1960
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before changing the backend.");
1961
+ return;
1962
+ }
1963
+
1964
+ setScreen("backend-picker");
1965
+ }, [appendSystemEvent, busy]);
1966
+
1967
+ const openProviderPicker = useCallback(() => {
1968
+ setPendingRouteProviderId(null);
1969
+ const gate = guardConfigMutation("backend", busy);
1970
+ if (!gate.allowed) {
1971
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before opening providers.");
1972
+ return;
1973
+ }
1974
+
1975
+ setScreen("provider-picker");
1976
+ void checkLocalProvider({ override: providerWorkspaceConfig.providers?.local }).then((result) => {
1977
+ if (!isMountedRef.current) return;
1978
+ if (result.diagnostics) {
1979
+ providerDiagnosticsRef.current["local"] = result.diagnostics as Record<string, string | number | boolean | null>;
1980
+ }
1981
+ if (result.status === "ready") {
1982
+ delete providerRouteErrorsRef.current["local"];
1983
+ } else {
1984
+ providerRouteErrorsRef.current["local"] = result.message ?? "Local provider unavailable.";
1985
+ }
1986
+ setRegistryNonce((n) => n + 1);
1987
+ }).catch(() => undefined);
1988
+ }, [appendSystemEvent, busy, providerWorkspaceConfig.providers]);
1989
+
1990
+ const setWorkspaceDefaultProviderWithNotice = useCallback((providerId: ProviderId) => {
1991
+ const provider = findProvider(providerRegistry, providerId);
1992
+ if (!provider) {
1993
+ appendErrorEvent("Provider unavailable", `Unknown provider: ${providerId}`);
1994
+ return;
1995
+ }
1996
+
1997
+ try {
1998
+ const nextConfig = setProviderWorkspaceDefault(providerWorkspaceConfig, providerId);
1999
+ saveProviderWorkspaceConfig(workspaceRoot, nextConfig);
2000
+ setProviderWorkspaceConfig(nextConfig);
2001
+ setScreen("main");
2002
+ const routeConfigured = isProviderRouteConfigured(providerId);
2003
+ appendSystemEvent(
2004
+ "Provider default updated",
2005
+ provider.routeMode === "in-codexa" && routeConfigured
2006
+ ? `${provider.displayName} is now the workspace default provider. Active chat route remains ${activeRouteProvider?.displayName ?? "OpenAI"} / ${model}.`
2007
+ : `${provider.displayName} is set as the workspace default, but in-Codexa routing is not configured yet. Active chat route remains ${activeRouteProvider?.displayName ?? "OpenAI"} / ${model}.`,
2008
+ );
2009
+ } catch (error) {
2010
+ const message = error instanceof Error ? error.message : "Unable to save provider workspace config.";
2011
+ appendErrorEvent("Provider default failed", message);
2012
+ }
2013
+ }, [activeRouteProvider, appendErrorEvent, appendSystemEvent, model, providerRegistry, providerWorkspaceConfig, workspaceRoot]);
2014
+
2015
+ const handleProviderAction = useCallback((providerId: ProviderId, action: ProviderPickerAction) => {
2016
+ if (action === "cancel") {
2017
+ setScreen("main");
2018
+ return;
2019
+ }
2020
+
2021
+ if (action === "set-default") {
2022
+ setWorkspaceDefaultProviderWithNotice(providerId);
2023
+ return;
2024
+ }
2025
+
2026
+ const provider = findProvider(providerRegistry, providerId);
2027
+ if (!provider) {
2028
+ setScreen("main");
2029
+ appendErrorEvent("Provider unavailable", `Unknown provider: ${providerId}`);
2030
+ return;
2031
+ }
2032
+
2033
+ if (action === "use-in-codexa") {
2034
+ if (!isProviderRoutableInCodexa(providerId)) {
2035
+ const message = provider.isDefault
2036
+ ? `${provider.displayName} is set as the workspace default, but in-Codexa routing is not configured yet.`
2037
+ : `${provider.displayName} in-Codexa routing is not configured yet.`;
2038
+ if (providerRouteErrorsRef.current[providerId] !== message) {
2039
+ appendSystemEvent("Provider route unavailable", message);
2040
+ providerRouteErrorsRef.current[providerId] = message;
2041
+ }
2042
+ return;
2043
+ }
2044
+
2045
+ if (providerId === "local") {
2046
+ appendSystemEvent("Model discovery", "Refreshing LM Studio metadata...");
2047
+ void checkLocalProvider({ override: providerWorkspaceConfig.providers?.local }).then((validation) => {
2048
+ if (!isMountedRef.current) return;
2049
+ if (validation.diagnostics) {
2050
+ providerDiagnosticsRef.current["local"] = validation.diagnostics as Record<string, string | number | boolean | null>;
2051
+ }
2052
+ if (validation.status !== "ready") {
2053
+ const message = validation.message ?? "Local provider unavailable.";
2054
+ providerRouteErrorsRef.current["local"] = message;
2055
+ appendSystemEvent("Provider route unavailable", message);
2056
+ setRegistryNonce((n) => n + 1);
2057
+ return;
2058
+ }
2059
+ delete providerRouteErrorsRef.current["local"];
2060
+ const selectedModel = typeof validation.diagnostics?.selectedModel === "string" && validation.diagnostics.selectedModel.trim()
2061
+ ? validation.diagnostics.selectedModel.trim()
2062
+ : provider.currentModel;
2063
+ setRegistryNonce((n) => n + 1);
2064
+ intendedInputModeRef.current = "chat/input";
2065
+ intendedFocusTargetRef.current = FOCUS_IDS.composer;
2066
+ setScreen("main");
2067
+ void setModelAndReasoningWithNotice(
2068
+ selectedModel as AvailableModel,
2069
+ (providerWorkspaceConfig.providers?.local?.currentReasoning ?? activeProviderRoute.reasoning ?? reasoningLevel) as ReasoningLevel,
2070
+ "local",
2071
+ );
2072
+ }).catch((error) => {
2073
+ if (!isMountedRef.current) return;
2074
+ appendErrorEvent("Local refresh failed", error instanceof Error ? error.message : String(error));
2075
+ });
2076
+ return;
2077
+ }
2078
+
2079
+ const workspaceProviderConfig = providerWorkspaceConfig.providers?.[providerId];
2080
+ const activeRoute = providerWorkspaceConfig.activeRoute;
2081
+ const isCurrentActive = activeRoute?.providerId === providerId;
2082
+
2083
+ // A "real" model is one that isn't a generic placeholder label from registry.ts
2084
+ const isRealModel = provider.currentModel &&
2085
+ !provider.currentModel.endsWith("default") &&
2086
+ provider.currentModel !== "Google default";
2087
+
2088
+ const providerReasoning = workspaceProviderConfig?.currentReasoning
2089
+ ?? (isCurrentActive ? activeRoute?.reasoning : undefined)
2090
+ ?? reasoningLevel;
2091
+
2092
+ if (isRealModel || isCurrentActive) {
2093
+ let geminiSelection: import("./core/providerRuntime/types.js").GeminiModelSelection | undefined;
2094
+ if (providerId === "google") {
2095
+ if (isCurrentActive && activeRoute?.modelSelection) {
2096
+ geminiSelection = activeRoute.modelSelection;
2097
+ } else if (workspaceProviderConfig?.currentModel) {
2098
+ geminiSelection = { kind: "manual", modelId: workspaceProviderConfig.currentModel };
2099
+ } else {
2100
+ geminiSelection = { kind: "auto", family: "gemini-3" };
2101
+ }
2102
+ }
2103
+
2104
+ intendedInputModeRef.current = "chat/input";
2105
+ intendedFocusTargetRef.current = FOCUS_IDS.composer;
2106
+ setScreen("main");
2107
+ void setModelAndReasoningWithNotice(
2108
+ (workspaceProviderConfig?.currentModel ?? provider.currentModel) as AvailableModel,
2109
+ providerReasoning as ReasoningLevel,
2110
+ providerId,
2111
+ geminiSelection,
2112
+ );
2113
+ return;
2114
+ }
2115
+
2116
+ // No clear model to use, open the picker.
2117
+ setPendingRouteProviderId(providerId);
2118
+ intendedInputModeRef.current = "model-picker";
2119
+ intendedFocusTargetRef.current = FOCUS_IDS.modelPicker;
2120
+ setScreen("model-picker");
2121
+ return;
2122
+ }
2123
+
2124
+ if (action === "select-model") {
2125
+ if (providerId === "openai" && !modelCapabilities) {
2126
+ void refreshModelCapabilities(false, false);
2127
+ }
2128
+ setPendingRouteProviderId(providerId);
2129
+ intendedInputModeRef.current = "model-picker";
2130
+ intendedFocusTargetRef.current = FOCUS_IDS.modelPicker;
2131
+ setScreen("model-picker");
2132
+ return;
2133
+ }
2134
+
2135
+ if (action === "refresh-models") {
2136
+ if (!isProviderRoutableInCodexa(providerId)) {
2137
+ const message = provider.isDefault
2138
+ ? `${provider.displayName} is set as the workspace default, but in-Codexa routing is not configured yet.`
2139
+ : `${provider.displayName} in-Codexa routing is not configured yet.`;
2140
+ if (providerRouteErrorsRef.current[providerId] !== message) {
2141
+ appendSystemEvent("Provider route unavailable", message);
2142
+ providerRouteErrorsRef.current[providerId] = message;
2143
+ }
2144
+ return;
2145
+ }
2146
+
2147
+ if (providerId === "openai") {
2148
+ void refreshModelCapabilities(true, true);
2149
+ appendSystemEvent("Model discovery", `Refreshing models for ${provider.displayName}.`);
2150
+ } else {
2151
+ const runtime = getProviderRuntime(providerId);
2152
+ if (runtime.refreshModels) {
2153
+ appendSystemEvent("Model discovery", providerId === "anthropic"
2154
+ ? "Refreshing Claude capabilities..."
2155
+ : `Refreshing models for ${provider.displayName}...`);
2156
+ void runtime.refreshModels({
2157
+ cwd: workspaceRoot,
2158
+ localConfig: providerId === "local" ? providerWorkspaceConfig.providers?.local : undefined,
2159
+ }).then((discovery) => {
2160
+ if (discovery.diagnostics) {
2161
+ providerDiagnosticsRef.current[providerId] = discovery.diagnostics as Record<string, string | number | boolean | null>;
2162
+ }
2163
+ if (discovery.status === "ready") {
2164
+ delete providerRouteErrorsRef.current[providerId];
2165
+ } else if (discovery.message) {
2166
+ providerRouteErrorsRef.current[providerId] = discovery.message;
2167
+ }
2168
+ appendSystemEvent(
2169
+ "Model discovery",
2170
+ discovery.status === "ready"
2171
+ ? discovery.message ?? `Loaded ${discovery.models.length} models for ${provider.displayName} (${discovery.models[0]?.source ?? "fallback"}).`
2172
+ : discovery.message ?? `${provider.displayName} model routing is not configured yet.`,
2173
+ );
2174
+ setRegistryNonce((n) => n + 1);
2175
+ });
2176
+ } else {
2177
+ const discovery = discoverProviderModels(providerId);
2178
+ appendSystemEvent(
2179
+ "Model discovery",
2180
+ discovery.status === "ready"
2181
+ ? `Loaded ${discovery.models.length} configured models for ${provider.displayName}.`
2182
+ : discovery.message ?? `${provider.displayName} model routing is not configured yet.`,
2183
+ );
2184
+ }
2185
+ }
2186
+ return;
2187
+ }
2188
+
2189
+ if (action === "run-diagnostics") {
2190
+ if (providerId !== "google" && providerId !== "local") {
2191
+ appendErrorEvent("Provider diagnostics unavailable", `Diagnostics are not implemented for ${provider.displayName}.`);
2192
+ return;
2193
+ }
2194
+
2195
+ setScreen("main");
2196
+ if (providerId === "local") {
2197
+ appendSystemEvent("Local diagnostics", "Running Local provider diagnostics...");
2198
+ void runLocalDiagnostics({
2199
+ localConfig: providerWorkspaceConfig.providers?.local,
2200
+ }).then((message) => {
2201
+ if (!isMountedRef.current) return;
2202
+ const discovery = discoverProviderModels("local");
2203
+ if (discovery.diagnostics) {
2204
+ providerDiagnosticsRef.current["local"] = discovery.diagnostics as Record<string, string | number | boolean | null>;
2205
+ }
2206
+ if (discovery.status === "ready") {
2207
+ delete providerRouteErrorsRef.current["local"];
2208
+ } else if (discovery.message) {
2209
+ providerRouteErrorsRef.current["local"] = discovery.message;
2210
+ }
2211
+ appendSystemEvent("Local diagnostics", message);
2212
+ setRegistryNonce((n) => n + 1);
2213
+ }).catch((error) => {
2214
+ if (!isMountedRef.current) return;
2215
+ appendErrorEvent("Local diagnostics failed", error instanceof Error ? error.message : String(error));
2216
+ });
2217
+ return;
2218
+ }
2219
+
2220
+ appendSystemEvent("Gemini diagnostics", "Running Gemini diagnostics...");
2221
+ const geminiCommandPath = providerWorkspaceConfig.providers?.google?.geminiCommandPath ?? runtimeConfig.geminiCommandPath;
2222
+ void runGeminiDiagnostics({
2223
+ cwd: workspaceRoot,
2224
+ runtime: geminiCommandPath ? { ...resolvedRuntimeConfig, geminiCommandPath } : resolvedRuntimeConfig,
2225
+ configuredPath: geminiCommandPath,
2226
+ selectedModel: activeProviderRoute.providerId === "google"
2227
+ ? activeProviderRoute.modelId
2228
+ : providerWorkspaceConfig.providers?.google?.currentModel ?? "gemini-3-flash-preview",
2229
+ selectedReasoning: activeProviderRoute.providerId === "google"
2230
+ ? activeProviderRoute.reasoning ?? reasoningLevel
2231
+ : providerWorkspaceConfig.providers?.google?.currentReasoning ?? reasoningLevel,
2232
+ }).then((message) => {
2233
+ if (!isMountedRef.current) return;
2234
+ appendSystemEvent("Gemini diagnostics", message);
2235
+ }).catch((error) => {
2236
+ if (!isMountedRef.current) return;
2237
+ const message = error instanceof Error ? error.message : "Gemini diagnostics failed.";
2238
+ appendErrorEvent("Gemini diagnostics failed", message);
2239
+ });
2240
+ return;
2241
+ }
2242
+
2243
+ if (busyRef.current) {
2244
+ appendSystemEvent("Busy", "Finish the current run before launching a provider CLI.");
2245
+ return;
2246
+ }
2247
+
2248
+ setScreen("main");
2249
+ appendSystemEvent(
2250
+ "Provider launch",
2251
+ `Suspending Codexa and launching ${provider.displayName}. Codexa will resume when the external CLI exits.`,
2252
+ );
2253
+
2254
+ void launchProviderCli(provider, {
2255
+ cwd: workspaceRoot,
2256
+ stdin,
2257
+ beforeLaunch: () => {
2258
+ terminalControl.setMouseReporting(false, "src/app.tsx:providerLaunch.disableMouse");
2259
+ stdout.write("\n");
2260
+ },
2261
+ afterLaunch: () => {
2262
+ terminalControl.setMouseReporting(mouseCapture, "src/app.tsx:providerLaunch.restoreMouse");
2263
+ forceRefreshCurrentTerminalTitle("provider_launch_return", false);
2264
+ },
2265
+ }).then((result) => {
2266
+ if (!isMountedRef.current) return;
2267
+ appendSystemEvent("Provider launch", result.message);
2268
+ }).catch((error) => {
2269
+ if (!isMountedRef.current) return;
2270
+ const message = error instanceof Error ? error.message : "Provider launch failed.";
2271
+ appendErrorEvent("Provider launch failed", message);
2272
+ });
2273
+ }, [
2274
+ activeProviderRoute,
2275
+ appendErrorEvent,
2276
+ appendSystemEvent,
2277
+ forceRefreshCurrentTerminalTitle,
2278
+ mouseCapture,
2279
+ providerRegistry,
2280
+ providerWorkspaceConfig.providers,
2281
+ modelCapabilities,
2282
+ reasoningLevel,
2283
+ refreshModelCapabilities,
2284
+ resolvedRuntimeConfig,
2285
+ runtimeConfig.geminiCommandPath,
2286
+ setWorkspaceDefaultProviderWithNotice,
2287
+ stdin,
2288
+ stdout,
2289
+ terminalControl,
2290
+ workspaceRoot,
2291
+ ]);
2292
+
2293
+ const openModelPicker = useCallback(() => {
2294
+ setPendingRouteProviderId(null);
2295
+ traceInputDebug("model_picker_open_request", getInputDebugSnapshot({
2296
+ handler: "openModelPicker",
2297
+ currentScreen: screen,
2298
+ }));
2299
+
2300
+ const gate = guardConfigMutation("model", busy);
2301
+ if (!gate.allowed) {
2302
+ traceInputDebug("model_picker_open_blocked", getInputDebugSnapshot({
2303
+ handler: "openModelPicker",
2304
+ reason: "busy",
2305
+ }));
2306
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before changing the model.");
2307
+ return;
2308
+ }
2309
+
2310
+ if (screen === "model-picker") {
2311
+ intendedInputModeRef.current = "model-picker";
2312
+ intendedFocusTargetRef.current = FOCUS_IDS.modelPicker;
2313
+ traceInputDebug("model_picker_open_duplicate", getInputDebugSnapshot({
2314
+ handler: "openModelPicker",
2315
+ focusTarget: FOCUS_IDS.modelPicker,
2316
+ }));
2317
+ focusManager.focus(FOCUS_IDS.modelPicker);
2318
+ return;
2319
+ }
2320
+
2321
+ // Kick off discovery if we don't already have capabilities. The helper is
2322
+ // single-flight, so repeated picker opens while discovery is in progress
2323
+ // subscribe to the existing promise instead of spawning duplicate jobs or
2324
+ // log entries. Open the picker immediately; it renders a loading state
2325
+ // until the promise resolves and state updates commit the model list.
2326
+ if (activeProviderRoute.providerId === "openai" && !modelCapabilities) {
2327
+ traceInputDebug("model_picker_loading_trigger", getInputDebugSnapshot({
2328
+ handler: "openModelPicker",
2329
+ }));
2330
+ void refreshModelCapabilities(false, false);
2331
+ }
2332
+
2333
+ intendedInputModeRef.current = "model-picker";
2334
+ intendedFocusTargetRef.current = FOCUS_IDS.modelPicker;
2335
+ setScreen("model-picker");
2336
+ traceInputDebug("model_picker_opened", getInputDebugSnapshot({
2337
+ handler: "openModelPicker",
2338
+ nextScreen: "model-picker",
2339
+ focusTarget: FOCUS_IDS.modelPicker,
2340
+ }));
2341
+ }, [activeProviderRoute.providerId, appendSystemEvent, busy, focusManager, getInputDebugSnapshot, modelCapabilities, refreshModelCapabilities, screen]);
2342
+
2343
+ const openModePicker = useCallback(() => {
2344
+ const gate = guardConfigMutation("mode", busy);
2345
+ if (!gate.allowed) {
2346
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before changing the mode.");
2347
+ return;
2348
+ }
2349
+
2350
+ setScreen("mode-picker");
2351
+ }, [appendSystemEvent, busy]);
2352
+
2353
+ const openReasoningPicker = useCallback(() => {
2354
+ const gate = guardConfigMutation("reasoning", busy);
2355
+ if (!gate.allowed) {
2356
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before changing the reasoning level.");
2357
+ return;
2358
+ }
2359
+
2360
+ if (!currentModelCapability?.supportedReasoningLevels?.length) {
2361
+ if (!modelCapabilitiesBusy) {
2362
+ void refreshModelCapabilities(true, true);
2363
+ }
2364
+ appendSystemEvent(
2365
+ "Reasoning unavailable",
2366
+ `Codex has not provided reasoning metadata for ${model}. No guessed reasoning levels will be shown.`,
2367
+ );
2368
+ return;
2369
+ }
2370
+
2371
+ setScreen("reasoning-picker");
2372
+ }, [appendSystemEvent, busy, currentModelCapability, model, modelCapabilitiesBusy, refreshModelCapabilities]);
2373
+
2374
+ const openThemePicker = useCallback(() => {
2375
+ const gate = guardConfigMutation("theme", busy);
2376
+ if (!gate.allowed) {
2377
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before changing the theme.");
2378
+ return;
2379
+ }
2380
+
2381
+ setScreen("theme-picker");
2382
+ }, [appendSystemEvent, busy]);
2383
+
2384
+ const openSettingsPanel = useCallback(() => {
2385
+ const gate = guardConfigMutation("mode", busy);
2386
+ if (!gate.allowed) {
2387
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before changing settings.");
2388
+ return;
2389
+ }
2390
+
2391
+ setScreen("settings-panel");
2392
+ }, [appendSystemEvent, busy]);
2393
+
2394
+ const openAuthPanel = useCallback(() => {
2395
+ if (busy) {
2396
+ appendSystemEvent("Busy", "Finish the current run before opening auth guidance.");
2397
+ return;
2398
+ }
2399
+
2400
+ setScreen("auth-panel");
2401
+ }, [appendSystemEvent, busy]);
2402
+
2403
+ const openPermissionsPanel = useCallback(() => {
2404
+ const gate = guardConfigMutation("mode", busy);
2405
+ if (!gate.allowed) {
2406
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before changing runtime policy.");
2407
+ return;
2408
+ }
2409
+
2410
+ setScreen("permissions-panel");
2411
+ }, [appendSystemEvent, busy]);
2412
+
2413
+ const openPermissionsApprovalPicker = useCallback(() => {
2414
+ setScreen("permissions-approval-picker");
2415
+ }, []);
2416
+
2417
+ const openPermissionsSandboxPicker = useCallback(() => {
2418
+ setScreen("permissions-sandbox-picker");
2419
+ }, []);
2420
+
2421
+ const openPermissionsNetworkPicker = useCallback(() => {
2422
+ setScreen("permissions-network-picker");
2423
+ }, []);
2424
+
2425
+ const openPermissionsAddWritableRoot = useCallback(() => {
2426
+ setScreen("permissions-add-writable-root");
2427
+ }, []);
2428
+
2429
+ const openPermissionsRemoveWritableRoot = useCallback(() => {
2430
+ if (runtimeConfig.policy.writableRoots.length === 0) {
2431
+ appendSystemEvent("Runtime policy", "No writable roots are configured.");
2432
+ return;
2433
+ }
2434
+
2435
+ setScreen("permissions-remove-writable-root");
2436
+ }, [appendSystemEvent, runtimeConfig.policy.writableRoots.length]);
2437
+
2438
+ const handlePermissionsPanelAction = useCallback((action: PermissionsPanelAction) => {
2439
+ switch (action) {
2440
+ case "approval-policy":
2441
+ openPermissionsApprovalPicker();
2442
+ return;
2443
+ case "sandbox":
2444
+ openPermissionsSandboxPicker();
2445
+ return;
2446
+ case "network":
2447
+ openPermissionsNetworkPicker();
2448
+ return;
2449
+ case "writable-roots-summary":
2450
+ appendSystemEvent(
2451
+ "Runtime policy",
2452
+ `Writable roots:\n${formatWritableRootsMessage(runtimeConfig.policy.writableRoots)}`,
2453
+ );
2454
+ return;
2455
+ case "writable-roots-add":
2456
+ openPermissionsAddWritableRoot();
2457
+ return;
2458
+ case "writable-roots-remove":
2459
+ openPermissionsRemoveWritableRoot();
2460
+ return;
2461
+ case "writable-roots-clear":
2462
+ clearWritableRootsWithNotice();
2463
+ return;
2464
+ default:
2465
+ return;
2466
+ }
2467
+ }, [
2468
+ appendSystemEvent,
2469
+ clearWritableRootsWithNotice,
2470
+ openPermissionsAddWritableRoot,
2471
+ openPermissionsApprovalPicker,
2472
+ openPermissionsNetworkPicker,
2473
+ openPermissionsRemoveWritableRoot,
2474
+ openPermissionsSandboxPicker,
2475
+ runtimeConfig.policy.writableRoots,
2476
+ ]);
2477
+
2478
+ const resetComposer = useCallback(() => {
2479
+ dispatchSession({ type: "RESET_INPUT" });
2480
+ }, [dispatchSession]);
2481
+
2482
+ const finalizePromptRun = useCallback((
2483
+ runId: number,
2484
+ turnId: number,
2485
+ status: "completed" | "failed" | "canceled",
2486
+ message?: string,
2487
+ response?: string,
2488
+ ) => {
2489
+ if (!isCurrentRun(activeRunIdRef.current, runId)) {
2490
+ appDiagLog(`FINALIZE_RUN_BOUNDARY: ignored stale runId=${runId} turnId=${turnId} status=${status} activeRunId=${activeRunIdRef.current}`);
2491
+ return false;
2492
+ }
2493
+ perf.mark("finalize_start");
2494
+
2495
+ const lifecycle = activeRunLifecycleRef.current;
2496
+ const timing = activeRunTimingRef.current?.runId === runId
2497
+ ? activeRunTimingRef.current
2498
+ : null;
2499
+ const finalMonotonicMs = performance.now();
2500
+ const durationMs = timing
2501
+ ? Math.max(0, Math.round(finalMonotonicMs - timing.submitMonotonicMs))
2502
+ : 0;
2503
+ renderDebug.traceEvent("run", "finalizeTiming", {
2504
+ runId,
2505
+ turnId,
2506
+ status,
2507
+ promptSubmitEpochMs: timing?.submitEpochMs,
2508
+ promptSubmitMonotonicMs: timing?.submitMonotonicMs,
2509
+ finalRenderMonotonicMs: finalMonotonicMs,
2510
+ elapsedWallMs: durationMs,
2511
+ });
2512
+ const cleanup = cleanupRef.current;
2513
+ cleanupRef.current = null;
2514
+ activeRunLifecycleRef.current = null;
2515
+ activeRunTimingRef.current = null;
2516
+ activeRunIdRef.current = null;
2517
+ activeTurnIdRef.current = null;
2518
+ appDiagLog([
2519
+ "FINALIZE_RUN_BOUNDARY:",
2520
+ `provider=${activeProviderRoute.providerId}`,
2521
+ `runId=${runId}`,
2522
+ `turnId=${turnId}`,
2523
+ `status=${status}`,
2524
+ `responseProvided=${response !== undefined}`,
2525
+ `responseLength=${response?.length ?? 0}`,
2526
+ `messagePresent=${Boolean(message?.trim())}`,
2527
+ `composerUnlockReason=finalizePromptRun:${status}`,
2528
+ ].join(" "));
2529
+ focusManager.focus(FOCUS_IDS.composer);
2530
+ appDiagLog(`COMPOSER_ACTIVE_AGAIN: reason=finalizePromptRun:${status} activeRunCleared=true focusTarget=${FOCUS_IDS.composer}`);
2531
+ cleanup?.();
2532
+ const safeMessage = message ? sanitizeTerminalOutput(message) : undefined;
2533
+ // When response is undefined, signal the reducer to preserve streamed content as-is.
2534
+ const safeResponse = response != null
2535
+ ? sanitizeTerminalOutput(response, { preserveTabs: false, tabSize: 2 })
2536
+ : undefined;
2537
+ const shouldParseActionRequired = lifecycle?.parseActionRequired ?? true;
2538
+ const parsed = status === "completed" && safeResponse?.trim()
2539
+ ? shouldParseActionRequired
2540
+ ? extractAssistantActionRequired(safeResponse)
2541
+ : { content: safeResponse, question: null as string | null }
2542
+ : { content: safeResponse, question: null as string | null };
2543
+ appDiagLog([
2544
+ "FINALIZE_RUN_PAYLOAD:",
2545
+ `provider=${activeProviderRoute.providerId}`,
2546
+ `runId=${runId}`,
2547
+ `turnId=${turnId}`,
2548
+ `status=${status}`,
2549
+ `safeResponseLength=${safeResponse?.length ?? 0}`,
2550
+ `parsedContentLength=${parsed.content?.length ?? 0}`,
2551
+ `assistantAppendCalledExpected=${Boolean(parsed.content?.trim())}`,
2552
+ `finalRunState=${status}`,
2553
+ ].join(" "));
2554
+ dispatchSession({
2555
+ type: "FINALIZE_RUN",
2556
+ runId,
2557
+ turnId,
2558
+ status,
2559
+ message: safeMessage,
2560
+ response: parsed.content,
2561
+ durationMs,
2562
+ responsePresentation: lifecycle?.responsePresentation,
2563
+ question: status === "completed" ? parsed.question : null,
2564
+ assistantFactory: () => ({
2565
+ id: createEventId(),
2566
+ type: "assistant",
2567
+ createdAt: Date.now(),
2568
+ content: parsed.content?.trim() ? parsed.content : "",
2569
+ contentChunks: [],
2570
+ turnId,
2571
+ }),
2572
+ });
2573
+ perf.mark("finalize_done");
2574
+ perf.setMeta("content_length", parsed.content?.length ?? 0);
2575
+ perf.setMeta("status", status);
2576
+ perf.setMeta("elapsed_wall_ms", durationMs);
2577
+ const perfSession = perf.getSession();
2578
+ if (perfSession) perf.persistSession(perfSession);
2579
+
2580
+ if (status === "completed") {
2581
+ lifecycle?.onCompleted?.({
2582
+ response: parsed.content ?? "",
2583
+ turnId,
2584
+ runId,
2585
+ });
2586
+ } else if (status === "failed") {
2587
+ lifecycle?.onFailed?.({
2588
+ message: safeMessage ?? "Run failed",
2589
+ turnId,
2590
+ runId,
2591
+ });
2592
+ } else {
2593
+ lifecycle?.onCanceled?.({
2594
+ turnId,
2595
+ runId,
2596
+ });
2597
+ }
2598
+
2599
+ return true;
2600
+ }, [activeProviderRoute.providerId, dispatchSession, focusManager]);
2601
+
2602
+ const cancelActiveRun = useCallback((retainHistory = true) => {
2603
+ const runId = activeRunIdRef.current;
2604
+ if (runId === null) return false;
2605
+ const promptTurnId = activeTurnIdRef.current;
2606
+
2607
+ if (!isCurrentRun(activeRunIdRef.current, runId)) {
2608
+ return false;
2609
+ }
2610
+
2611
+ const shellEvent = activeEvents.find((event) => event.type === "shell" && event.id === runId) as ShellEvent | undefined;
2612
+ const runEvent = activeEvents.find((event) => event.type === "run" && event.id === runId) as RunEvent | undefined;
2613
+
2614
+ if (retainHistory && runEvent) {
2615
+ return finalizePromptRun(runId, runEvent.turnId, "canceled");
2616
+ }
2617
+
2618
+ const cleanup = cleanupRef.current;
2619
+ const lifecycle = activeRunLifecycleRef.current;
2620
+ cleanupRef.current = null;
2621
+ activeRunLifecycleRef.current = null;
2622
+ activeRunTimingRef.current = null;
2623
+ activeRunIdRef.current = null;
2624
+ activeTurnIdRef.current = null;
2625
+ focusManager.focus(FOCUS_IDS.composer);
2626
+ cleanup?.();
2627
+
2628
+ if (retainHistory) {
2629
+ if (shellEvent) {
2630
+ activeRunLifecycleRef.current = null;
2631
+ activeRunTimingRef.current = null;
2632
+ dispatchSession({
2633
+ type: "FINALIZE_SHELL",
2634
+ shellId: runId,
2635
+ finalEvent: { ...shellEvent, status: "failed", exitCode: -1, durationMs: null },
2636
+ });
2637
+ } else {
2638
+ dispatchSession({ type: "REMOVE_ACTIVE_RUNTIME", runId, turnId: promptTurnId });
2639
+ const runEvent = activeEvents.find((event) => event.type === "run" && event.id === runId) as RunEvent | undefined;
2640
+ if (runEvent) {
2641
+ void finalizePromptRun(runId, runEvent.turnId, "canceled");
2642
+ } else {
2643
+ if (promptTurnId !== null) {
2644
+ lifecycle?.onCanceled?.({ turnId: promptTurnId, runId });
2645
+ }
2646
+ dispatchSession({ type: "REMOVE_ACTIVE_RUNTIME", runId, turnId: promptTurnId });
2647
+ }
2648
+ }
2649
+ } else {
2650
+ if (promptTurnId !== null) {
2651
+ lifecycle?.onCanceled?.({ turnId: promptTurnId, runId });
2652
+ }
2653
+ if (uiState.kind === "SHELL_RUNNING") {
2654
+ dispatchSession({ type: "UI_ACTION", action: { type: "SHELL_FINISHED", shellId: runId } });
2655
+ } else if (promptTurnId !== null) {
2656
+ dispatchSession({ type: "UI_ACTION", action: { type: "RUN_CANCELED", turnId: promptTurnId } });
2657
+ }
2658
+ dispatchSession({ type: "REMOVE_ACTIVE_RUNTIME", runId, turnId: promptTurnId });
2659
+ return true;
2660
+ }
2661
+
2662
+ if (uiState.kind === "SHELL_RUNNING") {
2663
+ dispatchSession({ type: "UI_ACTION", action: { type: "SHELL_FINISHED", shellId: runId } });
2664
+ } else if (promptTurnId !== null) {
2665
+ dispatchSession({ type: "UI_ACTION", action: { type: "RUN_CANCELED", turnId: promptTurnId } });
2666
+ }
2667
+
2668
+ return true;
2669
+ }, [activeEvents, dispatchSession, finalizePromptRun, focusManager, uiState.kind]);
2670
+
2671
+ const handleCancel = useCallback(() => {
2672
+ if (busy) {
2673
+ cancelActiveRun(true);
2674
+ return;
2675
+ }
2676
+ if (planFlow.kind === "collecting_feedback") {
2677
+ setPlanFlow((current) => cancelPlanFeedback(current));
2678
+ return;
2679
+ }
2680
+ if (planFlow.kind === "awaiting_action") {
2681
+ setPlanFlow(resetPlanFlow());
2682
+ appendSystemEvent("Plan review", "Plan review canceled. No changes were made.");
2683
+ return;
2684
+ }
2685
+ if (uiState.kind === "AWAITING_USER_ACTION" || uiState.kind === "ERROR") {
2686
+ dispatchSession({ type: "UI_ACTION", action: { type: "DISMISS_TRANSIENT" } });
2687
+ resetComposer();
2688
+ }
2689
+ }, [appendSystemEvent, busy, cancelActiveRun, dispatchSession, planFlow.kind, resetComposer, uiState.kind]);
2690
+
2691
+ const handleQuit = useCallback(() => {
2692
+ cancelActiveRun(false);
2693
+ exit();
2694
+ }, [cancelActiveRun, exit]);
2695
+
2696
+ const handleCopy = useCallback(async () => {
2697
+ // Build a full conversation transcript from all user prompts and assistant
2698
+ // responses in staticEvents, paired by turnId and sorted chronologically.
2699
+ type TurnPair = { createdAt: number; prompt: string; response: string | null };
2700
+ const turns = new Map<number, TurnPair>();
2701
+
2702
+ for (const event of staticEvents) {
2703
+ if (event.type === "user") {
2704
+ const existing = turns.get(event.turnId);
2705
+ if (!existing) {
2706
+ turns.set(event.turnId, { createdAt: event.createdAt, prompt: event.prompt, response: null });
2707
+ }
2708
+ } else if (event.type === "assistant") {
2709
+ const existing = turns.get(event.turnId);
2710
+ if (existing) {
2711
+ existing.response = event.content;
2712
+ }
2713
+ }
2714
+ }
2715
+
2716
+ if (turns.size === 0) {
2717
+ // After /clear, the conversation is empty and that's expected.
2718
+ // Don't show "Copy unavailable" error - maintain clean post-clear state.
2719
+ // Only show this error if the user tries to copy on a fresh session, not post-clear.
2720
+ return;
2721
+ }
2722
+
2723
+ // Sort turns by creation time and format as a readable dialogue.
2724
+ const lines: string[] = [];
2725
+ const sorted = [...turns.values()].sort((a, b) => a.createdAt - b.createdAt);
2726
+ for (const turn of sorted) {
2727
+ lines.push(`You: ${turn.prompt.trim()}`);
2728
+ if (turn.response?.trim()) {
2729
+ lines.push("");
2730
+ lines.push(`Codexa: ${turn.response.trim()}`);
2731
+ }
2732
+ lines.push("");
2733
+ }
2734
+ const transcript = lines.join("\n").trimEnd();
2735
+
2736
+ const ok = await copyToClipboard(transcript);
2737
+ const turnWord = turns.size === 1 ? "1 turn" : `${turns.size} turns`;
2738
+ appendSystemEvent(
2739
+ "Clipboard",
2740
+ ok
2741
+ ? `Copied full conversation (${turnWord}) to clipboard.`
2742
+ : "Clipboard unavailable.",
2743
+ );
2744
+ }, [appendSystemEvent, staticEvents]);
2745
+
2746
+ const savePlanFile = useCallback((planContent: string): string | null => {
2747
+ const filePath = savePlan(planContent, workspaceRoot);
2748
+ if (!filePath) {
2749
+ appendErrorEvent(
2750
+ "Plan file unavailable",
2751
+ "The generated plan could not be saved.",
2752
+ );
2753
+ }
2754
+ return filePath;
2755
+ }, [appendErrorEvent, workspaceRoot]);
2756
+
2757
+ const handleViewPlanFile = useCallback((planFilePath: string | null) => {
2758
+ if (!planFilePath) {
2759
+ appendErrorEvent("Plan file unavailable", "There is no saved plan file to view for this review.");
2760
+ return;
2761
+ }
2762
+
2763
+ const contents = readPlan(planFilePath);
2764
+ if (contents === null) {
2765
+ appendErrorEvent("Plan file unavailable", `The saved plan file is no longer available: ${planFilePath}`);
2766
+ return;
2767
+ }
2768
+
2769
+ const sanitized = normalizePlanReviewMarkdown(contents, workspaceRoot);
2770
+ appendSystemEvent("Plan file", [`Path: ${planFilePath}`, "", sanitized].join("\n"));
2771
+ }, [appendErrorEvent, appendSystemEvent, workspaceRoot]);
2772
+
2773
+ // ─── Stable composer-input callbacks ──────────────────────────────────────────
2774
+ // These use refs so the function identity never changes, avoiding
2775
+ // unnecessary downstream work even though the memo comparator on
2776
+ // MemoizedBottomComposer already skips callback checks.
2777
+ const handleChangeInput = useCallback((value: string, nextCursor: number) => {
2778
+ const safeValue = sanitizeTerminalInput(value);
2779
+ dispatchSession({ type: "SET_INPUT", value: safeValue, cursor: Math.min(nextCursor, safeValue.length) });
2780
+ }, [dispatchSession]);
2781
+
2782
+ const handleChangeValue = useCallback((value: string) => {
2783
+ const safeValue = sanitizeTerminalInput(value);
2784
+ dispatchSession({ type: "SET_INPUT", value: safeValue, cursor: Math.min(cursorRef.current, safeValue.length) });
2785
+ }, [dispatchSession]);
2786
+
2787
+ const handleChangeCursor = useCallback((nextCursor: number) => {
2788
+ const safeValue = sanitizeTerminalInput(inputValueRef.current);
2789
+ dispatchSession({ type: "SET_INPUT", value: safeValue, cursor: Math.min(nextCursor, safeValue.length) });
2790
+ }, [dispatchSession]);
2791
+
2792
+ const handleClear = useCallback(() => {
2793
+ writeCurrentTerminalTitleBeforeStateChange("before-clear");
2794
+ cancelActiveRun(false);
2795
+ activeTurnIdRef.current = null;
2796
+ activeRunLifecycleRef.current = null;
2797
+ activeRunTimingRef.current = null;
2798
+ setPlanFlow(resetPlanFlow());
2799
+ dispatchSession({ type: "CLEAR_TRANSCRIPT" });
2800
+ setConversationChars(0);
2801
+ setScreen("main");
2802
+ resetComposer();
2803
+ terminalControl.clearTranscript("src/app.tsx:handleClear");
2804
+ refreshTerminalTitle({
2805
+ terminalTitleMode,
2806
+ workspaceName: deriveTerminalTitle(workspaceRoot, "dir"),
2807
+ force: true,
2808
+ });
2809
+ }, [cancelActiveRun, dispatchSession, resetComposer, terminalControl, terminalTitleMode, workspaceRoot, writeCurrentTerminalTitleBeforeStateChange]);
2810
+
2811
+ const handleShellExecute = useCallback((command: string) => {
2812
+ const safeCommand = sanitizeTerminalInput(command).trim();
2813
+ const guardMessage = getShellWorkspaceGuardMessage(safeCommand, workspaceRoot, allowedWritableRoots);
2814
+ if (guardMessage) {
2815
+ appendErrorEvent("Shell command blocked", guardMessage);
2816
+ return;
2817
+ }
2818
+
2819
+ const shellId = createEventId();
2820
+ const startTime = Date.now();
2821
+ writeCurrentTerminalTitleBeforeStateChange("before-shell-start");
2822
+
2823
+ const initialEvent: ShellEvent = {
2824
+ id: shellId,
2825
+ createdAt: startTime,
2826
+ type: "shell",
2827
+ command: safeCommand,
2828
+ lines: [],
2829
+ stderrLines: [],
2830
+ summary: `Executing shell: ${safeCommand}`,
2831
+ status: "running",
2832
+ exitCode: null,
2833
+ durationMs: null,
2834
+ };
2835
+
2836
+ dispatchSession({ type: "SET_ACTIVE_EVENTS", events: [initialEvent] });
2837
+ activeRunLifecycleRef.current = null;
2838
+ activeRunTimingRef.current = null;
2839
+ activeRunIdRef.current = shellId;
2840
+ activeTurnIdRef.current = null;
2841
+ dispatchSession({ type: "UI_ACTION", action: { type: "SHELL_STARTED", shellId } });
2842
+
2843
+ let pendingStdout: string[] = [];
2844
+ let pendingStderr: string[] = [];
2845
+ let shellFlushTimer: ReturnType<typeof setTimeout> | null = null;
2846
+
2847
+ const flushShellLines = () => {
2848
+ if (shellFlushTimer) {
2849
+ clearTimeout(shellFlushTimer);
2850
+ shellFlushTimer = null;
2851
+ }
2852
+
2853
+ const stdoutLines = pendingStdout;
2854
+ const stderrLines = pendingStderr;
2855
+ pendingStdout = [];
2856
+ pendingStderr = [];
2857
+
2858
+ if (stdoutLines.length === 0 && stderrLines.length === 0) {
2859
+ return;
2860
+ }
2861
+
2862
+ startTransition(() => {
2863
+ if (stdoutLines.length > 0) {
2864
+ dispatchSession({ type: "UPDATE_SHELL_LINES", shellId, stream: "stdout", lines: stdoutLines });
2865
+ }
2866
+ if (stderrLines.length > 0) {
2867
+ dispatchSession({ type: "UPDATE_SHELL_LINES", shellId, stream: "stderr", lines: stderrLines });
2868
+ }
2869
+ });
2870
+ };
2871
+
2872
+ const scheduleShellFlush = () => {
2873
+ if (shellFlushTimer) return;
2874
+ shellFlushTimer = setTimeout(() => {
2875
+ shellFlushTimer = null;
2876
+ flushShellLines();
2877
+ }, LIVE_UPDATE_FLUSH_MS);
2878
+ };
2879
+
2880
+ const runner = runShellCommand(
2881
+ safeCommand,
2882
+ { cwd: workspaceRoot },
2883
+ {
2884
+ onProcessLifecycle: (event) => {
2885
+ reassertIntendedTerminalTitle({ reason: `shell-process-${event}` });
2886
+ },
2887
+ onStdout: (text) => {
2888
+ const lines = sanitizeTerminalLines(text.split(/\r?\n/));
2889
+ if (lines.length > 0) {
2890
+ pendingStdout.push(...lines);
2891
+ scheduleShellFlush();
2892
+ }
2893
+ },
2894
+ onStderr: (text) => {
2895
+ const lines = sanitizeTerminalLines(text.split(/\r?\n/));
2896
+ if (lines.length > 0) {
2897
+ pendingStderr.push(...lines);
2898
+ scheduleShellFlush();
2899
+ }
2900
+ },
2901
+ },
2902
+ );
2903
+
2904
+ cleanupRef.current = () => {
2905
+ if (shellFlushTimer) {
2906
+ clearTimeout(shellFlushTimer);
2907
+ shellFlushTimer = null;
2908
+ }
2909
+ runner.cancel();
2910
+ };
2911
+
2912
+ void runner.result.then((result) => {
2913
+ if (activeRunIdRef.current !== shellId) return;
2914
+ flushShellLines();
2915
+ activeRunIdRef.current = null;
2916
+ cleanupRef.current = null;
2917
+ focusManager.focus(FOCUS_IDS.composer);
2918
+
2919
+ const finalEvent: ShellEvent = {
2920
+ ...initialEvent,
2921
+ lines: sanitizeTerminalLines(result.stdout.split(/\r?\n/)),
2922
+ stderrLines: sanitizeTerminalLines(result.stderr.split(/\r?\n/)),
2923
+ summary: sanitizeTerminalOutput(summarizeCommandResult(safeCommand, result)),
2924
+ status: result.status === "completed" ? "completed" : "failed",
2925
+ exitCode: result.exitCode,
2926
+ durationMs: result.durationMs,
2927
+ };
2928
+
2929
+ dispatchSession({ type: "FINALIZE_SHELL", shellId, finalEvent });
2930
+ forceRefreshCurrentTerminalTitle("shell_output_complete", false);
2931
+ });
2932
+ }, [allowedWritableRoots, appendErrorEvent, dispatchSession, focusManager, forceRefreshCurrentTerminalTitle, workspaceRoot, writeCurrentTerminalTitleBeforeStateChange]);
2933
+
2934
+ const handleWorkspaceRelaunch = useCallback((targetPath: string) => {
2935
+ const gate = guardWorkspaceRelaunch(busy);
2936
+ if (!gate.allowed) {
2937
+ appendSystemEvent("Busy", gate.message ?? "Finish the current run before relaunching into another workspace.");
2938
+ return;
2939
+ }
2940
+
2941
+ const relaunchResult = createWorkspaceRelaunchPlan(targetPath, launchContext);
2942
+ if (!relaunchResult.ok) {
2943
+ appendErrorEvent("Workspace relaunch failed", relaunchResult.message);
2944
+ return;
2945
+ }
2946
+
2947
+ try {
2948
+ const child = spawn(relaunchResult.plan.executable, relaunchResult.plan.args, {
2949
+ cwd: relaunchResult.plan.cwd,
2950
+ env: relaunchResult.plan.env,
2951
+ stdio: "inherit",
2952
+ });
2953
+
2954
+ let launched = false;
2955
+ child.once("error", (error) => {
2956
+ if (launched) return;
2957
+ appendErrorEvent("Workspace relaunch failed", error.message);
2958
+ });
2959
+ child.once("spawn", () => {
2960
+ launched = true;
2961
+ exit();
2962
+ });
2963
+ } catch (error) {
2964
+ const message = error instanceof Error ? error.message : "Unknown relaunch failure";
2965
+ appendErrorEvent("Workspace relaunch failed", message);
2966
+ }
2967
+ }, [appendErrorEvent, appendSystemEvent, busy, exit, launchContext]);
2968
+
2969
+ const handleHistoryUp = useCallback(() => {
2970
+ dispatchSession({ type: "HISTORY_UP" });
2971
+ }, [dispatchSession]);
2972
+
2973
+ const handleHistoryDown = useCallback(() => {
2974
+ dispatchSession({ type: "HISTORY_DOWN" });
2975
+ }, [dispatchSession]);
2976
+
2977
+ const findUserPromptForTurn = useCallback((turnId: number): UserPromptEvent | null => {
2978
+ return findUserPrompt([...staticEvents, ...activeEvents], turnId);
2979
+ }, [activeEvents, staticEvents]);
2980
+
2981
+ const startPromptRun = useCallback((
2982
+ displayPrompt: string,
2983
+ providerPrompt: string,
2984
+ lifecycle: PromptRunLifecycle = {},
2985
+ ) => {
2986
+ const submitTiming = lifecycle.submitTiming ?? createPromptRunTiming();
2987
+ const safeDisplayPrompt = sanitizeTerminalInput(displayPrompt).trim();
2988
+ const safeProviderPrompt = sanitizeTerminalInput(providerPrompt).trim();
2989
+ if (!safeDisplayPrompt || !safeProviderPrompt) {
2990
+ appendErrorEvent("Prompt blocked", "The prompt only contained non-printable/control characters after sanitization.");
2991
+ return false;
2992
+ }
2993
+
2994
+ const requestedRuntime = mergeRuntimeConfig(runtimeConfig, lifecycle.runtimeOverride ?? {});
2995
+ const requestedMode = requestedRuntime.mode;
2996
+ const executionModeDecision = lifecycle.disableModeAutoUpgrade
2997
+ ? { mode: requestedMode, autoUpgraded: false }
2998
+ : resolveExecutionMode(requestedMode, safeProviderPrompt);
2999
+ const effectiveMode = executionModeDecision.mode;
3000
+ const runtimeConfigForTurn = {
3001
+ ...requestedRuntime,
3002
+ mode: effectiveMode,
3003
+ model: activeProviderRoute.modelId,
3004
+ reasoningLevel: activeProviderRoute.reasoning ?? requestedRuntime.reasoningLevel,
3005
+ ...(providerWorkspaceConfig.providers?.openai?.codexCommandPath
3006
+ ? { codexCommandPath: providerWorkspaceConfig.providers.openai.codexCommandPath }
3007
+ : {}),
3008
+ };
3009
+ let runtimeForTurn = resolveRuntimeConfig(runtimeConfigForTurn);
3010
+ const fastCleanupRun = isClearlySafeGeneratedCleanupRequest(safeProviderPrompt)
3011
+ && effectiveMode !== "suggest"
3012
+ && runtimeForTurn.policy.sandboxMode !== "read-only";
3013
+ if (fastCleanupRun && ["medium", "high", "xhigh"].includes(runtimeForTurn.reasoningLevel)) {
3014
+ runtimeForTurn = resolveRuntimeConfig({
3015
+ ...runtimeConfigForTurn,
3016
+ reasoningLevel: "low",
3017
+ });
3018
+ }
3019
+ if (executionModeDecision.autoUpgraded) {
3020
+ appendSystemEvent(
3021
+ "Mode auto-upgraded",
3022
+ "This prompt looks like a file-editing request, so the run is using Auto instead of Read-only.",
3023
+ );
3024
+ }
3025
+ if (fastCleanupRun) {
3026
+ appendSystemEvent(
3027
+ "Fast cleanup path",
3028
+ "Using a low-latency cleanup profile: shallow inspection, generated artifacts only, no branch/bootstrap setup.",
3029
+ );
3030
+ }
3031
+
3032
+ if (!provider.run) {
3033
+ appendErrorEvent(
3034
+ "Backend unavailable",
3035
+ `${provider.label} is a planned provider placeholder. Use Codexa Core for runnable execution in v1.`,
3036
+ );
3037
+ return false;
3038
+ }
3039
+ const runProvider = provider.run;
3040
+
3041
+ if (activeProviderRoute.providerId === "openai" && backend === "codex-subprocess") {
3042
+ const decision = getRunGateDecision(authStatus.state, {
3043
+ warnOnUnknown: authStatus.checkedAt > 0,
3044
+ });
3045
+ if (!decision.allowRun) {
3046
+ appendErrorEvent("Authentication required", decision.blockMessage ?? "Please sign in with `codex login`.");
3047
+ return false;
3048
+ }
3049
+ if (decision.warningMessage) {
3050
+ appendSystemEvent("Auth warning", decision.warningMessage);
3051
+ }
3052
+ }
3053
+
3054
+ const turnId = createTurnId();
3055
+ const userEvent: UserPromptEvent = {
3056
+ id: createEventId(),
3057
+ type: "user",
3058
+ createdAt: submitTiming.submitEpochMs,
3059
+ prompt: safeDisplayPrompt,
3060
+ turnId,
3061
+ };
3062
+ setConversationChars((count) => count + safeProviderPrompt.length);
3063
+
3064
+ const runId = createEventId();
3065
+ writeCurrentTerminalTitleBeforeStateChange("before-prompt-run-start");
3066
+ perf.startSession(String(runId));
3067
+ perf.mark("dispatch_start");
3068
+ perf.setMeta("fast_cleanup", fastCleanupRun);
3069
+ perf.setMeta("reasoning", runtimeForTurn.reasoningLevel);
3070
+ activeRunIdRef.current = runId;
3071
+ activeTurnIdRef.current = turnId;
3072
+ activeRunLifecycleRef.current = lifecycle;
3073
+ activeRunTimingRef.current = { ...submitTiming, runId, turnId };
3074
+ if (externalCliStatusRef.current === "idle") {
3075
+ dispatchSession({ type: "SET_EXTERNAL_CLI_STATUS", status: "starting" });
3076
+ }
3077
+ dispatchSession({
3078
+ type: "SUBMIT_PROMPT_RUN",
3079
+ historyValue: lifecycle.commitPrompt ? safeDisplayPrompt : undefined,
3080
+ turnId,
3081
+ runId,
3082
+ events: [
3083
+ userEvent,
3084
+ {
3085
+ ...createRunEvent({
3086
+ id: runId,
3087
+ backendId: backend,
3088
+ backendLabel: provider.label,
3089
+ runtime: runtimeForTurn,
3090
+ prompt: safeProviderPrompt,
3091
+ turnId,
3092
+ startedAtMs: submitTiming.submitEpochMs,
3093
+ responsePresentation: lifecycle.responsePresentation,
3094
+ approvedPlan: lifecycle.approvedPlan,
3095
+ }),
3096
+ summary: "Codexa is starting...",
3097
+ },
3098
+ ],
3099
+ });
3100
+
3101
+ let streamedAssistantContent = "";
3102
+ let legacyProgressSequence = 0;
3103
+ let firstRenderFired = false;
3104
+ let finalAnswerVisibleFired = false;
3105
+ let blockedCleanupFailureSurfaced = false;
3106
+
3107
+ let preRunSnapshot: ReturnType<typeof captureWorkspaceSnapshot> | null = null;
3108
+ let finalWorkspacePollDone = false;
3109
+ let activityTracker: ReturnType<typeof createWorkspaceActivityTracker> | null = null;
3110
+
3111
+ const liveScheduler = createLiveRenderScheduler({
3112
+ assistantFlushMs: LIVE_UPDATE_FLUSH_MS,
3113
+ progressOnlyFlushMs: PROGRESS_ONLY_FLUSH_MS,
3114
+ flush: (updates: LiveRenderUpdate[]) => {
3115
+ if (!isCurrentRun(activeRunIdRef.current, runId)) {
3116
+ return;
3117
+ }
3118
+
3119
+ if (!firstRenderFired && updates.some((update) => update.type === "assistant")) {
3120
+ firstRenderFired = true;
3121
+ perf.mark("first_render");
3122
+ }
3123
+
3124
+ dispatchSession({
3125
+ type: "RUN_APPLY_LIVE_UPDATES",
3126
+ turnId,
3127
+ runId,
3128
+ updates,
3129
+ assistantEventFactory: (chunk) => ({
3130
+ id: createEventId(),
3131
+ type: "assistant",
3132
+ createdAt: Date.now(),
3133
+ content: "",
3134
+ contentChunks: [chunk],
3135
+ turnId,
3136
+ }),
3137
+ });
3138
+ },
3139
+ });
3140
+
3141
+ const flushLiveUpdates = (): boolean => {
3142
+ perf.inc("flushes");
3143
+ return liveScheduler.flushNow();
3144
+ };
3145
+
3146
+ const traceLiveRunDiagnostics = (status: "completed" | "failed" | "canceled") => {
3147
+ const stats = liveScheduler.getStats();
3148
+ const now = performance.now();
3149
+ renderDebug.traceEvent("run", "liveBatchSummary", {
3150
+ runId,
3151
+ turnId,
3152
+ status,
3153
+ promptSubmitEpochMs: submitTiming.submitEpochMs,
3154
+ promptSubmitMonotonicMs: submitTiming.submitMonotonicMs,
3155
+ finalRenderMonotonicMs: now,
3156
+ elapsedWallMs: Math.max(0, Math.round(now - submitTiming.submitMonotonicMs)),
3157
+ providerEventsReceived: stats.providerEvents,
3158
+ uiFlushes: stats.flushes,
3159
+ averageFlushIntervalMs: stats.averageFlushIntervalMs,
3160
+ maxFlushIntervalMs: stats.maxFlushIntervalMs,
3161
+ });
3162
+ };
3163
+
3164
+ let stopProviderRun: (() => void) | undefined;
3165
+ let cancelScheduledProviderStart: (() => void) | null = null;
3166
+ let providerStartCancelled = false;
3167
+
3168
+ const startProviderRun = () => {
3169
+ if (providerStartCancelled || !isCurrentRun(activeRunIdRef.current, runId)) {
3170
+ return;
3171
+ }
3172
+
3173
+ // Capture the workspace state after the visible run has had a chance to
3174
+ // render, so first-prompt filesystem work cannot block initial progress.
3175
+ if (activeProviderRoute.providerId === "openai" && backend === "codex-subprocess") {
3176
+ preRunSnapshot = captureWorkspaceSnapshot(workspaceRoot);
3177
+ activityTracker = createWorkspaceActivityTracker({
3178
+ rootDir: workspaceRoot,
3179
+ initialSnapshot: preRunSnapshot,
3180
+ onActivity: (activity) => {
3181
+ if (!isCurrentRun(activeRunIdRef.current, runId)) return;
3182
+ liveScheduler.enqueue({ type: "activity", activity });
3183
+ },
3184
+ });
3185
+ }
3186
+
3187
+ perf.mark("provider_run_start");
3188
+ stopProviderRun = runProvider(
3189
+ safeProviderPrompt,
3190
+ { runtime: runtimeForTurn, workspaceRoot, projectInstructions },
3191
+ {
3192
+ onProcessLifecycle: (event) => {
3193
+ reassertIntendedTerminalTitle({ reason: `codex-process-${event}` });
3194
+ },
3195
+ onAssistantDelta: (chunk) => {
3196
+ const geminiBoundary = activeProviderRoute.providerId === "google";
3197
+ appDiagLog(`onAssistantDelta: provider=${activeProviderRoute.providerId} chunk.length=${chunk?.length ?? 0} isEmpty=${!chunk}`);
3198
+ if (geminiBoundary) {
3199
+ appDiagLog(`GEMINI_APP_BOUNDARY: onAssistantDelta received=yes nonEmpty=${Boolean(chunk)} runId=${runId} turnId=${turnId}`);
3200
+ }
3201
+ if (!chunk || !isCurrentRun(activeRunIdRef.current, runId)) {
3202
+ if (geminiBoundary) {
3203
+ appDiagLog(`GEMINI_APP_BOUNDARY: onAssistantDelta assistantAppendCalled=no reason=${!chunk ? "empty-chunk" : "stale-run"} runId=${runId} turnId=${turnId}`);
3204
+ }
3205
+ return;
3206
+ }
3207
+ const t0 = performance.now();
3208
+ const safeChunk = sanitizeTerminalOutput(chunk, { preserveTabs: false, tabSize: 2 });
3209
+ perf.accumulate("sanitize_ms", performance.now() - t0);
3210
+ perf.inc("chunks");
3211
+ if (!safeChunk) {
3212
+ appDiagLog(`onAssistantDelta: safeChunk empty after sanitize → no content queued to liveScheduler`);
3213
+ if (geminiBoundary) {
3214
+ appDiagLog(`GEMINI_APP_BOUNDARY: onAssistantDelta assistantAppendCalled=no reason=empty-after-sanitize runId=${runId} turnId=${turnId}`);
3215
+ }
3216
+ return;
3217
+ }
3218
+ appDiagLog(`onAssistantDelta: ASSISTANT_APPEND_PATH reached — queuing ${safeChunk.length} chars (liveScheduler→RUN_APPLY_LIVE_UPDATES→assistantEvent in activeEvents→FINALIZE_RUN→staticEvents)`);
3219
+ dispatchSession({ type: "SET_EXTERNAL_CLI_STATUS", status: "ready" });
3220
+ liveScheduler.enqueue({
3221
+ type: lifecycle.responsePresentation === "plan" ? "plan" : "assistant",
3222
+ chunk: safeChunk,
3223
+ });
3224
+ streamedAssistantContent += safeChunk;
3225
+ if (geminiBoundary) {
3226
+ appDiagLog(`GEMINI_APP_BOUNDARY: onAssistantDelta assistantAppendCalled=yes queuedLength=${safeChunk.length} totalStreamedLength=${streamedAssistantContent.length} runId=${runId} turnId=${turnId}`);
3227
+ }
3228
+ },
3229
+ onFinalAnswerObserved: (response) => {
3230
+ if (!isCurrentRun(activeRunIdRef.current, runId) || finalAnswerVisibleFired) return;
3231
+ const flushedLiveUpdates = flushLiveUpdates();
3232
+ const markFinalAnswerVisible = () => {
3233
+ if (!isCurrentRun(activeRunIdRef.current, runId) || finalAnswerVisibleFired) return;
3234
+ finalAnswerVisibleFired = true;
3235
+ const safeResponse = sanitizeTerminalOutput(response, { preserveTabs: false, tabSize: 2 });
3236
+ dispatchSession({
3237
+ type: "RUN_MARK_FINAL_ANSWER_OBSERVED",
3238
+ runId,
3239
+ turnId,
3240
+ response: safeResponse.trim() ? safeResponse : undefined,
3241
+ });
3242
+ perf.mark("final_answer_visible");
3243
+ };
3244
+
3245
+ if (flushedLiveUpdates) {
3246
+ setTimeout(markFinalAnswerVisible, 0);
3247
+ } else {
3248
+ markFinalAnswerVisible();
3249
+ }
3250
+ },
3251
+ onToolActivity: (activity) => {
3252
+ if (!isCurrentRun(activeRunIdRef.current, runId)) return;
3253
+ liveScheduler.enqueue({ type: "tool", activity });
3254
+ if (activity.status === "running") {
3255
+ return;
3256
+ }
3257
+ forceRefreshCurrentTerminalTitle("tool_activity_complete", true);
3258
+ if (fastCleanupRun && !blockedCleanupFailureSurfaced) {
3259
+ const blockedCleanupFailure = getBlockedCleanupFailure(activity);
3260
+ if (blockedCleanupFailure) {
3261
+ blockedCleanupFailureSurfaced = true;
3262
+ flushLiveUpdates();
3263
+ traceLiveRunDiagnostics("failed");
3264
+ void finalizePromptRun(runId, turnId, "failed", blockedCleanupFailure);
3265
+ return;
3266
+ }
3267
+ }
3268
+ },
3269
+ onResponse: (response) => {
3270
+ const geminiBoundary = activeProviderRoute.providerId === "google";
3271
+ appDiagLog(`onResponse: provider=${activeProviderRoute.providerId} response.length=${response?.length ?? 0}`);
3272
+ if (geminiBoundary) {
3273
+ appDiagLog(`GEMINI_APP_BOUNDARY: onResponse received=yes nonEmpty=${Boolean(response?.trim())} runId=${runId} turnId=${turnId}`);
3274
+ }
3275
+ if (!isCurrentRun(activeRunIdRef.current, runId)) {
3276
+ if (geminiBoundary) {
3277
+ appDiagLog(`GEMINI_APP_BOUNDARY: onResponse finalizeCalled=no reason=stale-run runId=${runId} turnId=${turnId}`);
3278
+ }
3279
+ return;
3280
+ }
3281
+ perf.mark("response_cb_start");
3282
+
3283
+ // Force one final synchronous workspace poll before finalizing the run.
3284
+ // This closes the race condition where the activity tracker's interval
3285
+ // hasn't fired yet and late file changes would be missed.
3286
+ if (activityTracker && preRunSnapshot) {
3287
+ try {
3288
+ perf.mark("snapshot_start");
3289
+ const finalSnapshot = captureWorkspaceSnapshot(workspaceRoot);
3290
+ perf.mark("snapshot_end");
3291
+ const lateActivity = diffWorkspaceSnapshots(preRunSnapshot, finalSnapshot);
3292
+ if (lateActivity.length > 0) {
3293
+ liveScheduler.enqueue({ type: "activity", activity: lateActivity });
3294
+ }
3295
+ } catch {
3296
+ // Non-fatal: best-effort final poll
3297
+ } finally {
3298
+ finalWorkspacePollDone = true;
3299
+ }
3300
+ }
3301
+
3302
+ const flushedLiveUpdates = flushLiveUpdates();
3303
+ const finalizeResponse = () => {
3304
+ if (!isCurrentRun(activeRunIdRef.current, runId)) return;
3305
+ const safeResponse = sanitizeTerminalOutput(response, { preserveTabs: false, tabSize: 2 });
3306
+ setConversationChars((count) => count + safeResponse.length);
3307
+
3308
+ // Validate response quality for write-intent/destructive prompts:
3309
+ // If the backend returned filler like "Hello." instead of execution
3310
+ // feedback, inject a warning so the user isn't silently misled.
3311
+ if (effectiveMode !== "suggest") {
3312
+ const hollow = detectHollowResponse(safeProviderPrompt, safeResponse);
3313
+ if (hollow.isHollow) {
3314
+ const formatted = formatHollowResponse(hollow, safeResponse);
3315
+ traceLiveRunDiagnostics("completed");
3316
+ void finalizePromptRun(runId, turnId, "completed", undefined, formatted);
3317
+ forceRefreshCurrentTerminalTitle("prompt_run_completed", false);
3318
+ return;
3319
+ }
3320
+ }
3321
+
3322
+ // If the streamed content matches the sanitized response (after
3323
+ // normalizing whitespace), pass undefined so FINALIZE_RUN preserves
3324
+ // the already-rendered streamed content — avoiding a visual flash.
3325
+ const normalizeWs = (s: string) => s.replace(/\s+/g, " ").trim();
3326
+ const streamedNorm = normalizeWs(streamedAssistantContent);
3327
+ const responseNorm = normalizeWs(safeResponse);
3328
+ const finalResponse =
3329
+ lifecycle.responsePresentation !== "plan" && streamedNorm && (
3330
+ streamedNorm === responseNorm ||
3331
+ (responseNorm.startsWith(streamedNorm) && streamedNorm.length / responseNorm.length > 0.8)
3332
+ )
3333
+ ? undefined
3334
+ : safeResponse;
3335
+ appDiagLog(`onResponse.finalizeResponse: safeResponse.length=${safeResponse.length} streamedContent.length=${streamedAssistantContent.length} finalResponse=${finalResponse === undefined ? "undefined(use-streamed)" : `${finalResponse.length}chars`}`);
3336
+ if (geminiBoundary) {
3337
+ const extractionStatus = safeResponse.trim() || streamedAssistantContent.trim()
3338
+ ? "assistant-text"
3339
+ : "completed-empty-assistant";
3340
+ appDiagLog([
3341
+ "GEMINI_APP_BOUNDARY:",
3342
+ `onResponse finalizeCalled=yes`,
3343
+ `extractionStatus=${extractionStatus}`,
3344
+ `safeResponseLength=${safeResponse.length}`,
3345
+ `streamedAssistantContentLength=${streamedAssistantContent.length}`,
3346
+ `finalResponseProvided=${finalResponse !== undefined}`,
3347
+ `finalRunState=completed`,
3348
+ `reasonComposerBecomesActive=FINALIZE_RUN_COMPLETED`,
3349
+ `runId=${runId}`,
3350
+ `turnId=${turnId}`,
3351
+ ].join(" "));
3352
+ }
3353
+ traceLiveRunDiagnostics("completed");
3354
+ void finalizePromptRun(runId, turnId, "completed", undefined, finalResponse);
3355
+ forceRefreshCurrentTerminalTitle("prompt_run_completed", false);
3356
+ };
3357
+
3358
+ if (flushedLiveUpdates) {
3359
+ setTimeout(finalizeResponse, 0);
3360
+ } else {
3361
+ finalizeResponse();
3362
+ }
3363
+ },
3364
+ onError: (message, rawOutput) => {
3365
+ if (!isCurrentRun(activeRunIdRef.current, runId)) return;
3366
+ const flushedLiveUpdates = flushLiveUpdates();
3367
+ const finalizeError = () => {
3368
+ if (!isCurrentRun(activeRunIdRef.current, runId)) return;
3369
+ const safeMessage = sanitizeTerminalOutput(message);
3370
+ const safeRawOutput = sanitizeTerminalOutput(rawOutput ?? "");
3371
+ const combinedOutput = [safeMessage, safeRawOutput].filter(Boolean).join("\n");
3372
+ const errorMessage = isLikelyAuthFailure(combinedOutput)
3373
+ ? [
3374
+ "Codexa reported an authentication/session error.",
3375
+ "Recovery:",
3376
+ " codex login",
3377
+ "",
3378
+ `Raw error: ${safeMessage}`,
3379
+ ].join("\n")
3380
+ : safeMessage;
3381
+
3382
+ if (isLikelyAuthFailure(combinedOutput)) {
3383
+ setRuntimeUnauthenticated("Auth/session failure detected in neural link.");
3384
+ }
3385
+
3386
+ traceLiveRunDiagnostics("failed");
3387
+ void finalizePromptRun(runId, turnId, "failed", errorMessage);
3388
+ forceRefreshCurrentTerminalTitle("prompt_run_failed", false);
3389
+ };
3390
+
3391
+ if (flushedLiveUpdates) {
3392
+ setTimeout(finalizeError, 0);
3393
+ } else {
3394
+ finalizeError();
3395
+ }
3396
+ },
3397
+ onProgress: (update) => {
3398
+ perf.inc("progress_updates");
3399
+ const safeText = sanitizeTerminalOutput(update.text);
3400
+ if (!safeText) return;
3401
+ if (isNoiseLine(safeText)) return;
3402
+ if (!isCurrentRun(activeRunIdRef.current, runId)) return;
3403
+ const safeUpdate: BackendProgressUpdate = {
3404
+ id: update.id?.trim() ? update.id : `legacy-progress-${++legacyProgressSequence}`,
3405
+ source: update.source,
3406
+ text: safeText,
3407
+ };
3408
+ liveScheduler.enqueue({ type: "progress", update: safeUpdate });
3409
+ },
3410
+ },
3411
+ );
3412
+ };
3413
+
3414
+ cancelScheduledProviderStart = schedulePromptRunStartAfterVisibleCommit(startProviderRun);
3415
+
3416
+ cleanupRef.current = () => {
3417
+ providerStartCancelled = true;
3418
+ cancelScheduledProviderStart?.();
3419
+ flushLiveUpdates();
3420
+ // Do one final sync poll before stopping the tracker to capture
3421
+ // any last-moment file changes that were in-flight.
3422
+ if (activityTracker && preRunSnapshot && !finalWorkspacePollDone) {
3423
+ try {
3424
+ const cleanupSnapshot = captureWorkspaceSnapshot(workspaceRoot);
3425
+ const lastActivity = diffWorkspaceSnapshots(preRunSnapshot, cleanupSnapshot);
3426
+ if (lastActivity.length > 0 && isCurrentRun(activeRunIdRef.current, runId)) {
3427
+ dispatchSession({ type: "RUN_APPEND_ACTIVITY", runId, activity: lastActivity });
3428
+ }
3429
+ } catch {
3430
+ // Non-fatal
3431
+ }
3432
+ }
3433
+ activityTracker?.stop();
3434
+ stopProviderRun?.();
3435
+ liveScheduler.cancel();
3436
+ };
3437
+
3438
+ return true;
3439
+ }, [
3440
+ appendErrorEvent,
3441
+ appendSystemEvent,
3442
+ authStatus.state,
3443
+ finalizePromptRun,
3444
+ forceRefreshCurrentTerminalTitle,
3445
+ writeCurrentTerminalTitleBeforeStateChange,
3446
+ mode,
3447
+ provider,
3448
+ projectInstructions,
3449
+ dispatchSession,
3450
+ setRuntimeUnauthenticated,
3451
+ runtimeConfig,
3452
+ workspaceRoot,
3453
+ ]);
3454
+
3455
+ const handleImportConfirm = useCallback(async () => {
3456
+ if (!pendingImport) return;
3457
+ const replacements: Array<{ rawPath: string; workspaceRelativePath: string }> = [];
3458
+ for (const file of pendingImport.files) {
3459
+ const destPath = await importExternalFile(file.srcPath, pendingImport.attachmentsDir);
3460
+ const relPath = path.relative(workspaceRoot, destPath).replace(/\\/g, "/");
3461
+ replacements.push({ rawPath: file.rawPath, workspaceRelativePath: relPath });
3462
+ }
3463
+ const rewrittenPrompt = rewritePromptWithImportedPaths(pendingImport.prompt, replacements);
3464
+ setPendingImport(null);
3465
+ setScreen("main");
3466
+ startPromptRun(rewrittenPrompt, rewrittenPrompt, { submitTiming: createPromptRunTiming(), commitPrompt: true });
3467
+ }, [pendingImport, workspaceRoot, startPromptRun]);
3468
+
3469
+ const handleImportCancel = useCallback(() => {
3470
+ if (!pendingImport) return;
3471
+ dispatchSession({ type: "SET_INPUT", value: pendingImport.prompt, cursor: pendingImport.prompt.length });
3472
+ setPendingImport(null);
3473
+ setScreen("main");
3474
+ }, [pendingImport, dispatchSession]);
3475
+
3476
+ const runPlanGeneration = useCallback((
3477
+ state: Extract<PlanFlowState, { kind: "generating" }>,
3478
+ displayPrompt: string,
3479
+ submitTiming?: PromptRunTiming,
3480
+ commitPrompt = false,
3481
+ ) => {
3482
+ const started = startPromptRun(
3483
+ displayPrompt,
3484
+ buildPlanningPrompt({
3485
+ task: state.originalPrompt,
3486
+ constraints: state.constraints,
3487
+ currentPlan: state.currentPlan,
3488
+ pendingFeedback: state.pendingFeedback,
3489
+ }),
3490
+ {
3491
+ runtimeOverride: {
3492
+ mode: "suggest",
3493
+ planMode: false,
3494
+ },
3495
+ disableModeAutoUpgrade: true,
3496
+ parseActionRequired: false,
3497
+ responsePresentation: "plan",
3498
+ submitTiming,
3499
+ commitPrompt,
3500
+ onCompleted: ({ response }) => {
3501
+ const nextPlan = response.trim();
3502
+ if (!nextPlan) {
3503
+ setPlanFlow(resetPlanFlow());
3504
+ appendErrorEvent("Plan generation failed", "Plan mode expected a concrete plan, but the response was empty.");
3505
+ return;
3506
+ }
3507
+ const planFilePath = savePlanFile(nextPlan);
3508
+ setPlanFlow((current) => finishPlanGeneration(current, nextPlan, planFilePath));
3509
+ },
3510
+ onFailed: () => {
3511
+ setPlanFlow(resetPlanFlow());
3512
+ },
3513
+ onCanceled: () => {
3514
+ setPlanFlow(resetPlanFlow());
3515
+ },
3516
+ },
3517
+ );
3518
+
3519
+ if (!started) {
3520
+ setPlanFlow(resetPlanFlow());
3521
+ }
3522
+
3523
+ return started;
3524
+ }, [appendErrorEvent, savePlanFile, startPromptRun]);
3525
+
3526
+ const startApprovedPlanExecution = useCallback((state: Extract<PlanFlowState, { kind: "awaiting_action" }>) => {
3527
+ const submitTiming = createPromptRunTiming();
3528
+ setPlanFlow(approvePlanExecution(state));
3529
+ const started = startPromptRun(
3530
+ state.originalPrompt,
3531
+ buildPlanExecutionPrompt({
3532
+ task: state.originalPrompt,
3533
+ approvedPlan: state.currentPlan,
3534
+ constraints: state.constraints,
3535
+ }),
3536
+ {
3537
+ approvedPlan: state.currentPlan,
3538
+ submitTiming,
3539
+ runtimeOverride: {
3540
+ mode: state.executionMode,
3541
+ planMode: false,
3542
+ },
3543
+ onCompleted: () => {
3544
+ setPlanFlow(resetPlanFlow());
3545
+ },
3546
+ onFailed: () => {
3547
+ setPlanFlow(resetPlanFlow());
3548
+ },
3549
+ onCanceled: () => {
3550
+ setPlanFlow(resetPlanFlow());
3551
+ },
3552
+ },
3553
+ );
3554
+
3555
+ if (!started) {
3556
+ setPlanFlow(state);
3557
+ }
3558
+ }, [startPromptRun]);
3559
+
3560
+ const handlePlanAction = useCallback((action: PlanActionValue) => {
3561
+ if (planFlow.kind !== "awaiting_action") {
3562
+ return;
3563
+ }
3564
+
3565
+ switch (action) {
3566
+ case "implement":
3567
+ startApprovedPlanExecution(planFlow);
3568
+ return;
3569
+ case "revise":
3570
+ setPlanFlow(beginPlanFeedback(planFlow, "revise"));
3571
+ return;
3572
+ case "cancel":
3573
+ setPlanFlow(resetPlanFlow());
3574
+ appendSystemEvent("Plan review", "Plan review canceled. No changes were made.");
3575
+ return;
3576
+ default:
3577
+ return;
3578
+ }
3579
+ }, [appendSystemEvent, planFlow, startApprovedPlanExecution]);
3580
+
3581
+ const handlePlanFeedbackSubmit = useCallback((value: string) => {
3582
+ if (planFlow.kind !== "collecting_feedback") {
3583
+ return;
3584
+ }
3585
+
3586
+ const feedback = sanitizeTerminalInput(value).trim();
3587
+ if (!feedback) {
3588
+ appendSystemEvent("Plan review", "Add a short revision note or constraint before submitting.");
3589
+ return;
3590
+ }
3591
+
3592
+ const nextState = submitPlanFeedback(planFlow, feedback);
3593
+ if (nextState.kind !== "generating") {
3594
+ return;
3595
+ }
3596
+
3597
+ setPlanFlow(nextState);
3598
+ runPlanGeneration(nextState, feedback, createPromptRunTiming());
3599
+ }, [appendSystemEvent, planFlow, runPlanGeneration]);
3600
+
3601
+ useEffect(() => {
3602
+ if (initialPromptSubmittedRef.current || busy) {
3603
+ return;
3604
+ }
3605
+
3606
+ const initialPrompt = sanitizeTerminalInput(launchArgs.initialPrompt ?? "").trim();
3607
+ if (!initialPrompt) {
3608
+ return;
3609
+ }
3610
+
3611
+ initialPromptSubmittedRef.current = true;
3612
+
3613
+ const workspaceGuardMessage = getPromptWorkspaceGuardMessage(initialPrompt, workspaceRoot, allowedWritableRoots);
3614
+ if (workspaceGuardMessage) {
3615
+ appendErrorEvent("Workspace boundary", workspaceGuardMessage);
3616
+ return;
3617
+ }
3618
+
3619
+ if (planMode) {
3620
+ const nextPlanState = startPlanGeneration(initialPrompt, mode);
3621
+ setPlanFlow(nextPlanState);
3622
+ runPlanGeneration(nextPlanState, initialPrompt, createPromptRunTiming(), true);
3623
+ return;
3624
+ }
3625
+
3626
+ startPromptRun(initialPrompt, initialPrompt, { submitTiming: createPromptRunTiming(), commitPrompt: true });
3627
+ }, [
3628
+ allowedWritableRoots,
3629
+ appendErrorEvent,
3630
+ busy,
3631
+ dispatchSession,
3632
+ launchArgs.initialPrompt,
3633
+ mode,
3634
+ planMode,
3635
+ runPlanGeneration,
3636
+ startPromptRun,
3637
+ workspaceRoot,
3638
+ ]);
3639
+
3640
+ const handleSubmit = useCallback(() => {
3641
+ const submitTiming = createPromptRunTiming();
3642
+ perf.mark("submit");
3643
+ const value = sanitizeTerminalInput(inputValue).trim();
3644
+ if (!value) return;
3645
+
3646
+ // Special perf debug command (not routed through handleCommand)
3647
+ if (value === "/perf") {
3648
+ const session = perf.getSession();
3649
+ const summary = session
3650
+ ? perf.buildSummary(session)
3651
+ : "No perf data recorded yet. Set CODEXA_PERF=1 and send a prompt first.";
3652
+ appendSystemEvent("Perf report", summary);
3653
+ dispatchSession({ type: "PUSH_HISTORY", value });
3654
+ resetComposer();
3655
+ return;
3656
+ }
3657
+
3658
+ // ========== COMMAND ROUTING (before AWAITING_USER_ACTION) ==========
3659
+ // Shell execution: ! prefix routes directly to the terminal
3660
+ if (value.startsWith("!")) {
3661
+ if (busy) return;
3662
+ const shellCmd = value.slice(1).trim();
3663
+ if (!shellCmd) return;
3664
+ dispatchSession({ type: "PUSH_HISTORY", value });
3665
+ resetComposer();
3666
+ handleShellExecute(shellCmd);
3667
+ return;
3668
+ }
3669
+
3670
+ // Parse slash commands (/ prefix) and question-prefix invalid commands (? prefix)
3671
+ const commandResult = handleCommand(value, {
3672
+ config: layeredRuntimeConfig,
3673
+ runtime: runtimeConfig,
3674
+ resolvedRuntime: resolvedRuntimeConfig,
3675
+ settings: {
3676
+ workspaceDisplayMode,
3677
+ terminalTitleMode,
3678
+ showBusyLoader,
3679
+ },
3680
+ workspace: workspaceCommandContext,
3681
+ tokensUsed: estimateTokens(conversationChars),
3682
+ modelCapabilities: activeRouteModelCapabilities,
3683
+ routeStatusMessage,
3684
+ activeRouteProviderLabel: activeRouteProvider?.displayName ?? "OpenAI",
3685
+ projectInstructions: projectInstructionsLoad,
3686
+ });
3687
+ const isCommand = commandResult !== null;
3688
+
3689
+ if (isCommand) {
3690
+ // Internal commands should NOT be added to PUSH_HISTORY or sent to provider
3691
+ resetComposer();
3692
+
3693
+ switch (commandResult.action) {
3694
+ case "exit":
3695
+ handleQuit();
3696
+ return;
3697
+ case "clear":
3698
+ handleClear();
3699
+ return;
3700
+ case "backend":
3701
+ if (commandResult.value) {
3702
+ setBackendWithNotice(commandResult.value as AvailableBackend);
3703
+ }
3704
+ return;
3705
+ case "model":
3706
+ if (commandResult.value) {
3707
+ setModelWithNotice(commandResult.value as AvailableModel);
3708
+ }
3709
+ return;
3710
+ case "mode":
3711
+ if (commandResult.value) {
3712
+ setModeWithNotice(commandResult.value as AvailableMode);
3713
+ }
3714
+ return;
3715
+ case "reasoning":
3716
+ if (commandResult.value) {
3717
+ setReasoningWithNotice(commandResult.value as ReasoningLevel);
3718
+ }
3719
+ return;
3720
+ case "plan_mode":
3721
+ if (commandResult.value) {
3722
+ setPlanModeWithNotice(commandResult.value === "on");
3723
+ } else if (commandResult.message) {
3724
+ appendSystemEvent("Plan mode", commandResult.message);
3725
+ }
3726
+ return;
3727
+ case "status":
3728
+ case "runtime_writable_roots_list":
3729
+ if (commandResult.message) {
3730
+ appendSystemEvent("Runtime status", commandResult.message);
3731
+ }
3732
+ return;
3733
+ case "route_status":
3734
+ if (commandResult.message) {
3735
+ appendSystemEvent("Route status", commandResult.message);
3736
+ }
3737
+ return;
3738
+ case "config_status":
3739
+ if (commandResult.message) {
3740
+ appendSystemEvent("Config", commandResult.message);
3741
+ }
3742
+ return;
3743
+ case "config_trust_status":
3744
+ if (commandResult.message) {
3745
+ appendSystemEvent("Config trust", commandResult.message);
3746
+ }
3747
+ return;
3748
+ case "config_trust_set":
3749
+ if (commandResult.value) {
3750
+ setProjectTrustWithNotice(commandResult.value === "on");
3751
+ }
3752
+ return;
3753
+ case "permissions_status":
3754
+ if (commandResult.message) {
3755
+ appendSystemEvent("Permissions", commandResult.message);
3756
+ }
3757
+ return;
3758
+ case "runtime_approval_policy":
3759
+ if (commandResult.value) {
3760
+ setApprovalPolicyWithNotice(commandResult.value as RuntimeApprovalPolicy);
3761
+ } else if (commandResult.message) {
3762
+ appendSystemEvent("Runtime policy", commandResult.message);
3763
+ }
3764
+ return;
3765
+ case "runtime_sandbox_mode":
3766
+ if (commandResult.value) {
3767
+ setSandboxModeWithNotice(commandResult.value as RuntimeSandboxMode);
3768
+ } else if (commandResult.message) {
3769
+ appendSystemEvent("Runtime policy", commandResult.message);
3770
+ }
3771
+ return;
3772
+ case "runtime_network_access":
3773
+ if (commandResult.value) {
3774
+ setNetworkAccessWithNotice(commandResult.value as RuntimeNetworkAccess);
3775
+ } else if (commandResult.message) {
3776
+ appendSystemEvent("Runtime policy", commandResult.message);
3777
+ }
3778
+ return;
3779
+ case "runtime_writable_roots_add":
3780
+ if (commandResult.value) {
3781
+ addWritableRootWithNotice(commandResult.value);
3782
+ }
3783
+ return;
3784
+ case "runtime_writable_roots_remove":
3785
+ if (commandResult.value) {
3786
+ removeWritableRootWithNotice(commandResult.value);
3787
+ }
3788
+ return;
3789
+ case "runtime_writable_roots_clear":
3790
+ clearWritableRootsWithNotice();
3791
+ return;
3792
+ case "runtime_service_tier":
3793
+ if (commandResult.value) {
3794
+ setServiceTierWithNotice(commandResult.value as RuntimeServiceTier);
3795
+ } else if (commandResult.message) {
3796
+ appendSystemEvent("Runtime policy", commandResult.message);
3797
+ }
3798
+ return;
3799
+ case "diagnose_github": {
3800
+ const remoteUrl = getLocalGitRemoteUrl();
3801
+ const repo = parseRepoIdentity(remoteUrl);
3802
+ const ghCli = checkGhCli();
3803
+ const localGit = checkLocalGitRemote();
3804
+ const localGitWrite = checkLocalGitWrite();
3805
+
3806
+ // MCP connector check: since TUI can't call MCP, we mark it as unknown
3807
+ // or rely on the agent to fill this in if it's the one running the command.
3808
+ const connector: DiagnosticResult = {
3809
+ path: "GitHub connector/MCP",
3810
+ status: "FAIL",
3811
+ evidence: "TUI cannot directly probe MCP",
3812
+ blocker: "Run /diagnose through the agent for a full probe",
3813
+ recommendedUse: false,
3814
+ };
3815
+
3816
+ const recommendedFlow = classifyDiagnostics(repo, ghCli, localGit, localGitWrite, connector);
3817
+
3818
+ // Instead of console.log, we'll format a message for appendSystemEvent
3819
+ const tableLines = [
3820
+ "Path | Status | Evidence | Blocker",
3821
+ "--------------------|---------|-------------------------------|---------------------------",
3822
+ `${ghCli.path.padEnd(20)}| ${ghCli.status.padEnd(8)}| ${(ghCli.evidence || "").substring(0, 30).padEnd(30)}| ${ghCli.blocker || ""}`,
3823
+ `${localGit.path.padEnd(20)}| ${localGit.status.padEnd(8)}| ${(localGit.evidence || "").substring(0, 30).padEnd(30)}| ${localGit.blocker || ""}`,
3824
+ `${localGitWrite.path.padEnd(20)}| ${localGitWrite.status.padEnd(8)}| ${(localGitWrite.evidence || "").substring(0, 30).padEnd(30)}| ${localGitWrite.blocker || ""}`,
3825
+ `${connector.path.padEnd(20)}| ${connector.status.padEnd(8)}| ${(connector.evidence || "").substring(0, 30).padEnd(30)}| ${connector.blocker || ""}`,
3826
+ ];
3827
+
3828
+ const summary = [
3829
+ ...tableLines,
3830
+ "",
3831
+ `Resolved repo: ${repo ? `${repo.owner}/${repo.repo}` : "Unknown"}`,
3832
+ `Recommended PR flow: ${recommendedFlow}`,
3833
+ ].join("\n");
3834
+
3835
+ appendSystemEvent("GitHub Diagnostics", summary);
3836
+ return;
3837
+ }
3838
+ case "runtime_personality":
3839
+ if (commandResult.value) {
3840
+ setPersonalityWithNotice(commandResult.value as RuntimePersonality);
3841
+ } else if (commandResult.message) {
3842
+ appendSystemEvent("Runtime policy", commandResult.message);
3843
+ }
3844
+ return;
3845
+ case "auth":
3846
+ if (commandResult.value) {
3847
+ setAuthPreferenceWithNotice(commandResult.value as AuthPreference);
3848
+ }
3849
+ return;
3850
+ case "diagnose_providers": {
3851
+ const lines: string[] = ["Provider CLI diagnostics:"];
3852
+ const diags = providerDiagnosticsRef.current;
3853
+ const providerIds = ["openai", "anthropic", "google"] as const;
3854
+ const labels: Record<string, string> = { openai: "OpenAI/Codex", anthropic: "Anthropic/Claude", google: "Google/Gemini" };
3855
+ for (const id of providerIds) {
3856
+ const diag = diags[id];
3857
+ lines.push(`\n ${labels[id] ?? id}:`);
3858
+ if (!diag) {
3859
+ lines.push(" No diagnostic data (provider not yet validated).");
3860
+ continue;
3861
+ }
3862
+ const fields: Array<[string, string]> = [
3863
+ ["resolvedCommand", "Resolved command"],
3864
+ ["executablePath", "Executable path"],
3865
+ ["loggedIn", "Logged in"],
3866
+ ["authMethod", "Auth method"],
3867
+ ["subscriptionType", "Subscription"],
3868
+ ["apiProvider", "API provider"],
3869
+ ["modelSource", "Model source"],
3870
+ ];
3871
+ for (const [key, label] of fields) {
3872
+ if (diag[key] != null) lines.push(` ${label}: ${diag[key]}`);
3873
+ }
3874
+ }
3875
+ appendSystemEvent("Provider diagnostics", lines.join("\n"));
3876
+ return;
3877
+ }
3878
+ case "setting_status":
3879
+ if (commandResult.message) {
3880
+ appendSystemEvent("Settings", commandResult.message);
3881
+ }
3882
+ return;
3883
+ case "setting_workspace_display":
3884
+ if (commandResult.value) {
3885
+ setWorkspaceDisplayModeWithNotice(commandResult.value as WorkspaceDisplayMode);
3886
+ } else if (commandResult.message) {
3887
+ appendSystemEvent("Settings", commandResult.message);
3888
+ }
3889
+ return;
3890
+ case "setting_terminal_title":
3891
+ if (commandResult.value) {
3892
+ setTerminalTitleModeWithNotice(commandResult.value as TerminalTitleMode);
3893
+ } else if (commandResult.message) {
3894
+ appendSystemEvent("Settings", commandResult.message);
3895
+ }
3896
+ return;
3897
+ case "setting_busy_loader":
3898
+ if (commandResult.value) {
3899
+ const nextShowBusyLoader = commandResult.value === "true";
3900
+ setShowBusyLoader(nextShowBusyLoader);
3901
+ appendSystemEvent("Settings", `Busy loader ${nextShowBusyLoader ? "enabled" : "disabled"}.`);
3902
+ } else if (commandResult.message) {
3903
+ appendSystemEvent("Settings", commandResult.message);
3904
+ }
3905
+ return;
3906
+ case "theme":
3907
+ if (commandResult.value) {
3908
+ setThemeSelection((currentTheme) => commitThemeSelection(currentTheme, commandResult.value!));
3909
+ if (commandResult.message) {
3910
+ appendSystemEvent("Theme", commandResult.message);
3911
+ }
3912
+ }
3913
+ return;
3914
+ case "themes":
3915
+ if (commandResult.message) {
3916
+ appendSystemEvent("Themes", commandResult.message);
3917
+ }
3918
+ return;
3919
+ case "login":
3920
+ appendSystemEvent("Login guidance", getLoginGuidance());
3921
+ return;
3922
+ case "logout":
3923
+ appendSystemEvent("Logout guidance", getLogoutGuidance());
3924
+ return;
3925
+ case "auth_status":
3926
+ void refreshAuthStatus(true);
3927
+ return;
3928
+ case "open_backend_picker":
3929
+ openBackendPicker();
3930
+ return;
3931
+ case "open_provider_picker":
3932
+ openProviderPicker();
3933
+ return;
3934
+ case "open_model_picker":
3935
+ openModelPicker();
3936
+ return;
3937
+ case "open_mode_picker":
3938
+ openModePicker();
3939
+ return;
3940
+ case "open_reasoning_picker":
3941
+ openReasoningPicker();
3942
+ return;
3943
+ case "open_settings_panel":
3944
+ openSettingsPanel();
3945
+ return;
3946
+ case "open_theme_picker":
3947
+ openThemePicker();
3948
+ return;
3949
+ case "open_permissions_panel":
3950
+ openPermissionsPanel();
3951
+ return;
3952
+ case "open_auth_panel":
3953
+ openAuthPanel();
3954
+ return;
3955
+ case "mouse_toggle": {
3956
+ const nextMouse = !(mouseOverride ?? (terminalMouseMode === "wheel"));
3957
+ setMouseOverride(nextMouse);
3958
+ appendSystemEvent(
3959
+ "Mouse mode updated",
3960
+ nextMouse
3961
+ ? "SGR mouse capture on — in-app wheel scroll active. Native drag-select requires Shift (Windows Terminal). Run /mouse to disable."
3962
+ : "SGR mouse capture off — native drag-select and native wheel scroll restored. Run /mouse to re-enable.",
3963
+ );
3964
+ return;
3965
+ }
3966
+ case "verbose_toggle": {
3967
+ if (commandResult.message) {
3968
+ appendSystemEvent("Debug", commandResult.message);
3969
+ return;
3970
+ }
3971
+ setVerboseMode((current) => !current);
3972
+ appendSystemEvent(
3973
+ "Verbose mode",
3974
+ verboseMode
3975
+ ? "Verbose mode disabled — showing concise output."
3976
+ : "Verbose mode enabled — showing detailed processing info.",
3977
+ );
3978
+ return;
3979
+ }
3980
+ case "copy":
3981
+ void handleCopy();
3982
+ return;
3983
+ case "workspace_relaunch":
3984
+ if (commandResult.value) {
3985
+ handleWorkspaceRelaunch(commandResult.value);
3986
+ }
3987
+ return;
3988
+ case "workspace":
3989
+ case "backends":
3990
+ if (commandResult.message) {
3991
+ appendSystemEvent("Command", commandResult.message);
3992
+ }
3993
+ return;
3994
+ case "models":
3995
+ if (!modelCapabilities) {
3996
+ void refreshModelCapabilities(false, true);
3997
+ }
3998
+ if (commandResult.message) {
3999
+ appendSystemEvent("Command", commandResult.message);
4000
+ }
4001
+ return;
4002
+ case "help":
4003
+ case "unknown":
4004
+ if (commandResult.message) {
4005
+ appendSystemEvent("Command", commandResult.message);
4006
+ }
4007
+ return;
4008
+ default:
4009
+ if (commandResult.message) {
4010
+ appendSystemEvent("Command", commandResult.message);
4011
+ }
4012
+ return;
4013
+ }
4014
+ }
4015
+
4016
+ // ========== NORMAL PROMPT SUBMISSION (after command routing) ==========
4017
+ // Check for follow-up answer submission
4018
+ if (uiState.kind === "AWAITING_USER_ACTION") {
4019
+ const originalUserEvent = findUserPromptForTurn(uiState.turnId);
4020
+ if (!originalUserEvent) {
4021
+ appendErrorEvent("Follow-up unavailable", "The original turn could not be found, so the answer could not be resumed.");
4022
+ dispatchSession({ type: "UI_ACTION", action: { type: "DISMISS_TRANSIENT" } });
4023
+ return;
4024
+ }
4025
+
4026
+ if (busy) return;
4027
+ startPromptRun(value, buildFollowUpPrompt({
4028
+ originalPrompt: originalUserEvent.prompt,
4029
+ assistantQuestion: uiState.question,
4030
+ userAnswer: value,
4031
+ }), { submitTiming, commitPrompt: true });
4032
+ return;
4033
+ }
4034
+
4035
+ // Check if app is busy for normal prompts
4036
+ if (!isCommand && busy) {
4037
+ return;
4038
+ }
4039
+
4040
+ // Validate workspace access for normal prompts
4041
+ const outsideViolations = findOutsideWorkspacePaths(value, workspaceRoot, allowedWritableRoots);
4042
+ if (outsideViolations.length > 0) {
4043
+ if (runtimeConfig.policy.allowExternalFileImport) {
4044
+ const attachmentsDir = path.isAbsolute(runtimeConfig.policy.attachmentDir)
4045
+ ? runtimeConfig.policy.attachmentDir
4046
+ : path.join(workspaceRoot, runtimeConfig.policy.attachmentDir);
4047
+ const importFiles: PendingImportFile[] = outsideViolations.map((v) => ({
4048
+ srcPath: v.normalizedPath,
4049
+ rawPath: v.rawPath,
4050
+ destFilename: path.basename(v.normalizedPath),
4051
+ isImage: isImageFile(v.normalizedPath),
4052
+ }));
4053
+ setPendingImport({ prompt: value, files: importFiles, attachmentsDir });
4054
+ setScreen("import-confirmation");
4055
+ return;
4056
+ }
4057
+ const workspaceGuardMessage = getPromptWorkspaceGuardMessage(value, workspaceRoot, allowedWritableRoots);
4058
+ if (workspaceGuardMessage) {
4059
+ appendErrorEvent("Workspace boundary", workspaceGuardMessage);
4060
+ return;
4061
+ }
4062
+ }
4063
+
4064
+ // Submit to provider or plan mode
4065
+ if (planMode) {
4066
+ const nextPlanState = startPlanGeneration(value, mode);
4067
+ setPlanFlow(nextPlanState);
4068
+ runPlanGeneration(nextPlanState, value, submitTiming, true);
4069
+ return;
4070
+ }
4071
+ startPromptRun(value, value, { submitTiming, commitPrompt: true });
4072
+ }, [
4073
+ allowedWritableRoots,
4074
+ appendErrorEvent,
4075
+ appendSystemEvent,
4076
+ busy,
4077
+ buildFollowUpPrompt,
4078
+ conversationChars,
4079
+ dispatchSession,
4080
+ findUserPromptForTurn,
4081
+ focusManager,
4082
+ handleCopy,
4083
+ handleClear,
4084
+ handleQuit,
4085
+ handleShellExecute,
4086
+ handlePlanFeedbackSubmit,
4087
+ handleWorkspaceRelaunch,
4088
+ inputValue,
4089
+ layeredRuntimeConfig,
4090
+ modelCapabilities,
4091
+ mode,
4092
+ openAuthPanel,
4093
+ openBackendPicker,
4094
+ openProviderPicker,
4095
+ openModePicker,
4096
+ openModelPicker,
4097
+ openPermissionsPanel,
4098
+ openReasoningPicker,
4099
+ openSettingsPanel,
4100
+ planMode,
4101
+ refreshAuthStatus,
4102
+ resetComposer,
4103
+ resolvedRuntimeConfig,
4104
+ runPlanGeneration,
4105
+ runtimeConfig,
4106
+ addWritableRootWithNotice,
4107
+ clearWritableRootsWithNotice,
4108
+ removeWritableRootWithNotice,
4109
+ setApprovalPolicyWithNotice,
4110
+ setAuthPreferenceWithNotice,
4111
+ setBackendWithNotice,
4112
+ setNetworkAccessWithNotice,
4113
+ setModeWithNotice,
4114
+ setModelWithNotice,
4115
+ setPlanModeWithNotice,
4116
+ togglePlanModeWithNotice,
4117
+ setPersonalityWithNotice,
4118
+ setProjectTrustWithNotice,
4119
+ setReasoningWithNotice,
4120
+ setSandboxModeWithNotice,
4121
+ setServiceTierWithNotice,
4122
+ showBusyLoader,
4123
+ startPromptRun,
4124
+ themeSelection.committedTheme,
4125
+ uiState,
4126
+ workspaceCommandContext,
4127
+ workspaceDisplayMode,
4128
+ workspaceRoot,
4129
+ ]);
4130
+
4131
+ const modelDisplayName = useMemo(() => {
4132
+ if (activeProviderRoute.providerId === "anthropic") {
4133
+ const modelLabel = currentModelCapability?.label ?? activeProviderRoute.modelId;
4134
+ return `Claude Code CLI / ${modelLabel} / reasoning: ${formatReasoningLabel(activeProviderRoute.reasoning ?? reasoningLevel)}`;
4135
+ }
4136
+ if (activeProviderRoute.providerId === "google" && activeProviderRoute.modelSelection) {
4137
+ if (activeProviderRoute.modelSelection.kind === "auto") {
4138
+ return `auto ${activeProviderRoute.modelSelection.family === "gemini-3" ? "Gemini 3" : "Gemini 2.5"}`;
4139
+ }
4140
+ }
4141
+ if (activeProviderRoute.providerId === "local") {
4142
+ return activeProviderRoute.modelId;
4143
+ }
4144
+ return model;
4145
+ }, [
4146
+ activeProviderRoute.modelId,
4147
+ activeProviderRoute.modelSelection,
4148
+ activeProviderRoute.providerId,
4149
+ activeProviderRoute.reasoning,
4150
+ currentModelCapability?.label,
4151
+ model,
4152
+ reasoningLevel,
4153
+ ]);
4154
+ const composerReasoningLevel = activeProviderRoute.providerId === "anthropic"
4155
+ ? ""
4156
+ : activeProviderRoute.reasoning ?? reasoningLevel;
4157
+
4158
+ // Memoize the composer element so AppShell's memo check (prev.composer ===
4159
+ // next.composer) passes during streaming. Without this, a new JSX element is
4160
+ // created on every App render, forcing the entire AppShell tree (header +
4161
+ // timeline + footer) to re-render on every 25ms streaming flush.
4162
+ const composerElement = useMemo(() => {
4163
+ if (planFlow.kind === "awaiting_action") {
4164
+ if (!hasVisibleTranscriptPlan) {
4165
+ return (
4166
+ <Text color={activeTheme.MUTED}>
4167
+ Plan could not be displayed. Please ask Codexa to regenerate the plan.
4168
+ </Text>
4169
+ );
4170
+ }
4171
+ return (
4172
+ <PlanActionPicker
4173
+ cols={terminalLayout.cols}
4174
+ onSelect={handlePlanAction}
4175
+ onCancel={handleCancel}
4176
+ />
4177
+ );
4178
+ }
4179
+ if (planFlow.kind === "collecting_feedback") {
4180
+ return (
4181
+ <TextEntryPanel
4182
+ focusId={FOCUS_IDS.composer}
4183
+ title="Update plan"
4184
+ subtitle="Describe what should change. Enter regenerates the plan."
4185
+ inputLabel="Update"
4186
+ placeholder={planFlow.mode === "revise"
4187
+ ? "e.g. keep it to one file and add tests"
4188
+ : "e.g. keep it minimal and avoid touching other files"}
4189
+ footerHint="Enter regenerate Esc back Backspace delete"
4190
+ initialValue={initialRevisionText}
4191
+ onSubmit={(value) => {
4192
+ setInitialRevisionText("");
4193
+ handlePlanFeedbackSubmit(value);
4194
+ }}
4195
+ onCancel={() => setPlanFlow((current) => cancelPlanFeedback(current))}
4196
+ />
4197
+ );
4198
+ }
4199
+ return (
4200
+ <MemoizedBottomComposer
4201
+ key={composerInstanceKey}
4202
+ layout={terminalLayout}
4203
+ uiState={uiState}
4204
+ mode={mode}
4205
+ model={modelDisplayName}
4206
+ themeName={activeThemeName}
4207
+ reasoningLevel={composerReasoningLevel}
4208
+ planMode={planMode}
4209
+ showBusyLoader={showBusyLoader}
4210
+ tokensUsed={estimateTokens(conversationChars)}
4211
+ modelSpec={currentModelSpec}
4212
+ value={inputValue}
4213
+ cursor={cursor}
4214
+ onChangeInput={handleChangeInput}
4215
+ onSubmit={handleSubmit}
4216
+ onCancel={handleCancel}
4217
+ onChangeValue={handleChangeValue}
4218
+ onChangeCursor={handleChangeCursor}
4219
+ onHistoryUp={handleHistoryUp}
4220
+ onHistoryDown={handleHistoryDown}
4221
+ onOpenBackendPicker={openBackendPicker}
4222
+ onOpenProviderPicker={openProviderPicker}
4223
+ onOpenModelPicker={openModelPicker}
4224
+ onOpenModePicker={openModePicker}
4225
+ onOpenThemePicker={openThemePicker}
4226
+ onOpenAuthPanel={openAuthPanel}
4227
+ onTogglePlanMode={togglePlanModeWithNotice}
4228
+ onClear={handleClear}
4229
+ onCycleMode={cycleModeWithNotice}
4230
+ onQuit={handleQuit}
4231
+ activeProviderId={activeProviderRoute.providerId}
4232
+ externalCliStatus={sessionState.externalCliStatus}
4233
+ />
4234
+ );
4235
+ }, [
4236
+ planFlow,
4237
+ initialRevisionText,
4238
+ handlePlanAction,
4239
+ handleCancel,
4240
+ handlePlanFeedbackSubmit,
4241
+ hasVisibleTranscriptPlan,
4242
+ activeTheme.MUTED,
4243
+ composerInstanceKey,
4244
+ terminalLayout,
4245
+ uiState,
4246
+ mode,
4247
+ modelDisplayName,
4248
+ activeThemeName,
4249
+ composerReasoningLevel,
4250
+ planMode,
4251
+ showBusyLoader,
4252
+ conversationChars,
4253
+ currentModelSpec,
4254
+ inputValue,
4255
+ cursor,
4256
+ handleChangeInput,
4257
+ handleSubmit,
4258
+ handleChangeValue,
4259
+ handleChangeCursor,
4260
+ handleHistoryUp,
4261
+ handleHistoryDown,
4262
+ openBackendPicker,
4263
+ openProviderPicker,
4264
+ openModelPicker,
4265
+ openModePicker,
4266
+ openThemePicker,
4267
+ openAuthPanel,
4268
+ togglePlanModeWithNotice,
4269
+ handleClear,
4270
+ cycleModeWithNotice,
4271
+ handleQuit,
4272
+ activeProviderRoute.providerId,
4273
+ ]);
4274
+
4275
+ // ─── Render ──────────────────────────────────────────────────────────────────
4276
+
4277
+ // Plan review is shown inline in the Timeline, not as a separate overlay.
4278
+ const mainPanelElement = null;
4279
+
4280
+ return (
4281
+ <ThemeProvider theme={activeThemeName} customTheme={customTheme}>
4282
+ <AppShell
4283
+ layout={terminalLayout}
4284
+ screen={screen}
4285
+ authState={authStatus.state}
4286
+ workspaceLabel={workspaceLabel}
4287
+ workspaceRoot={workspaceRoot}
4288
+ runtimeSummary={runtimeSummary}
4289
+ staticEvents={staticEvents}
4290
+ activeEvents={activeEvents}
4291
+ uiState={uiState}
4292
+ verboseMode={verboseMode}
4293
+ mouseCapture={mouseCapture}
4294
+ onMouseActivity={resetMouseIdle}
4295
+ selectionProfile={selectionProfile}
4296
+ clearCount={sessionState.clearCount}
4297
+ headerConfig={headerConfig}
4298
+ panel={
4299
+ <>
4300
+ {screen === "backend-picker" && (
4301
+ <BackendPicker
4302
+ currentBackend={backend}
4303
+ onSelect={(value) => setBackendWithNotice(value as AvailableBackend)}
4304
+ onCancel={() => setScreen("main")}
4305
+ />
4306
+ )}
4307
+
4308
+ {screen === "provider-picker" && (
4309
+ <ProviderPicker
4310
+ layout={terminalLayout}
4311
+ providers={providerRegistry}
4312
+ onAction={handleProviderAction}
4313
+ onCancel={() => {
4314
+ setPendingRouteProviderId(null);
4315
+ setScreen("main");
4316
+ }}
4317
+ initialProviderId={pendingRouteProviderId ?? undefined}
4318
+ />
4319
+ )}
4320
+
4321
+ {screen === "model-picker" && (
4322
+ <ModelPickerScreen
4323
+ layout={terminalLayout}
4324
+ models={modelPickerModels}
4325
+ currentModel={modelPickerCurrentModel}
4326
+ currentReasoning={modelPickerCurrentReasoning}
4327
+ activeProviderLabel={modelPickerProviderLabel}
4328
+ isLoading={modelPickerProviderId === "openai" && modelCapabilitiesBusy && modelPickerModels.length === 0}
4329
+ emptyMessage={modelPickerEmptyMessage}
4330
+ onSelect={(m, r, geminiSelection) => {
4331
+ if (pendingRouteProviderId && pendingRouteProviderId !== activeProviderRoute.providerId) {
4332
+ // Non-active provider: save as provider default without switching the active route.
4333
+ // User must click "Use in Codexa" to validate and activate.
4334
+ persistProviderDefaultModelAndReasoning(pendingRouteProviderId, m, r);
4335
+ appendSystemEvent(
4336
+ "Provider model saved",
4337
+ `${modelPickerProviderLabel} default model set to ${m} with reasoning ${formatReasoningLabel(r)}. Choose "Use in Codexa" to activate this provider.`,
4338
+ );
4339
+ setScreen("provider-picker");
4340
+ } else {
4341
+ void setModelAndReasoningWithNotice(m as AvailableModel, r as ReasoningLevel, modelPickerProviderId, geminiSelection);
4342
+ }
4343
+ }}
4344
+ onCancel={() => {
4345
+ setPendingRouteProviderId(null);
4346
+ returnToChatMode();
4347
+ }}
4348
+ />
4349
+ )}
4350
+
4351
+ {screen === "mode-picker" && (
4352
+ <ModePicker
4353
+ currentMode={mode}
4354
+ onSelect={(value) => setModeWithNotice(value as AvailableMode)}
4355
+ onCancel={() => setScreen("main")}
4356
+ />
4357
+ )}
4358
+
4359
+ {screen === "reasoning-picker" && (
4360
+ <ReasoningPicker
4361
+ currentModel={activeProviderRoute.modelId}
4362
+ currentReasoning={activeProviderRoute.reasoning ?? reasoningLevel}
4363
+ reasoningLevels={currentReasoningCapabilities}
4364
+ defaultReasoning={currentModelCapability?.defaultReasoningLevel ?? null}
4365
+ sourceLabel={currentReasoningSourceLabel}
4366
+ onSelect={(value) => setReasoningWithNotice(value as ReasoningLevel)}
4367
+ onCancel={() => setScreen("main")}
4368
+ />
4369
+ )}
4370
+
4371
+ {screen === "auth-panel" && (
4372
+ <AuthPanel
4373
+ focusId={FOCUS_IDS.authPanel}
4374
+ provider={provider}
4375
+ authPreference={authPreference}
4376
+ authStatus={authStatus}
4377
+ authStatusBusy={authStatusBusy}
4378
+ onSetPreference={(value) => setAuthPreferenceWithNotice(value as AuthPreference)}
4379
+ onRefreshAuthStatus={() => {
4380
+ void refreshAuthStatus(false);
4381
+ }}
4382
+ onClose={() => setScreen("main")}
4383
+ />
4384
+ )}
4385
+
4386
+ {screen === "permissions-panel" && (
4387
+ <PermissionsPanel
4388
+ runtime={runtimeConfig}
4389
+ resolvedRuntime={resolvedRuntimeConfig}
4390
+ onSelect={handlePermissionsPanelAction}
4391
+ onCancel={() => setScreen("main")}
4392
+ />
4393
+ )}
4394
+
4395
+ {screen === "permissions-approval-picker" && (
4396
+ <SelectionPanel
4397
+ focusId={FOCUS_IDS.permissionsApprovalPicker}
4398
+ title="Approval Policy"
4399
+ subtitle="Choose how Codexa should handle approval prompts."
4400
+ items={AVAILABLE_APPROVAL_POLICIES.map((item) => ({
4401
+ label: item.id === runtimeConfig.policy.approvalPolicy
4402
+ ? `${item.label} ✓`
4403
+ : item.label,
4404
+ value: item.id,
4405
+ }))}
4406
+ onSelect={(value) => {
4407
+ setApprovalPolicyWithNotice(value as RuntimeApprovalPolicy);
4408
+ setScreen("permissions-panel");
4409
+ }}
4410
+ onCancel={() => setScreen("permissions-panel")}
4411
+ />
4412
+ )}
4413
+
4414
+ {screen === "permissions-sandbox-picker" && (
4415
+ <SelectionPanel
4416
+ focusId={FOCUS_IDS.permissionsSandboxPicker}
4417
+ title="Sandbox Mode"
4418
+ subtitle="Choose the effective filesystem sandbox for future runs."
4419
+ items={AVAILABLE_SANDBOX_MODES.map((item) => ({
4420
+ label: item.id === runtimeConfig.policy.sandboxMode
4421
+ ? `${item.label} ✓`
4422
+ : item.label,
4423
+ value: item.id,
4424
+ }))}
4425
+ onSelect={(value) => {
4426
+ setSandboxModeWithNotice(value as RuntimeSandboxMode);
4427
+ setScreen("permissions-panel");
4428
+ }}
4429
+ onCancel={() => setScreen("permissions-panel")}
4430
+ />
4431
+ )}
4432
+
4433
+ {screen === "permissions-network-picker" && (
4434
+ <SelectionPanel
4435
+ focusId={FOCUS_IDS.permissionsNetworkPicker}
4436
+ title="Network Access"
4437
+ subtitle="Choose whether network access is enabled for future runs."
4438
+ items={AVAILABLE_NETWORK_ACCESS_VALUES.map((item) => ({
4439
+ label: item.id === runtimeConfig.policy.networkAccess
4440
+ ? `${item.label} ✓`
4441
+ : item.label,
4442
+ value: item.id,
4443
+ }))}
4444
+ onSelect={(value) => {
4445
+ setNetworkAccessWithNotice(value as RuntimeNetworkAccess);
4446
+ setScreen("permissions-panel");
4447
+ }}
4448
+ onCancel={() => setScreen("permissions-panel")}
4449
+ />
4450
+ )}
4451
+
4452
+ {screen === "permissions-add-writable-root" && (
4453
+ <TextEntryPanel
4454
+ focusId={FOCUS_IDS.permissionsAddWritableRoot}
4455
+ title="Add Writable Root"
4456
+ subtitle="Enter an absolute path or a path relative to the locked workspace."
4457
+ placeholder="relative\\or\\absolute\\path"
4458
+ inputLabel="Path"
4459
+ footerHint="Enter save Esc cancel Backspace delete"
4460
+ onSubmit={(value) => {
4461
+ if (!value.trim()) {
4462
+ appendSystemEvent("Runtime policy", "Writable root path cannot be empty.");
4463
+ return;
4464
+ }
4465
+ addWritableRootWithNotice(value);
4466
+ setScreen("permissions-panel");
4467
+ }}
4468
+ onCancel={() => setScreen("permissions-panel")}
4469
+ />
4470
+ )}
4471
+
4472
+ {screen === "permissions-remove-writable-root" && (
4473
+ <SelectionPanel
4474
+ focusId={FOCUS_IDS.permissionsRemoveWritableRoot}
4475
+ title="Remove Writable Root"
4476
+ subtitle="Select a configured writable root to remove."
4477
+ items={runtimeConfig.policy.writableRoots.map((root) => ({
4478
+ label: root,
4479
+ value: root,
4480
+ }))}
4481
+ onSelect={(value) => {
4482
+ removeWritableRootWithNotice(value);
4483
+ setScreen("permissions-panel");
4484
+ }}
4485
+ onCancel={() => setScreen("permissions-panel")}
4486
+ />
4487
+ )}
4488
+
4489
+ {screen === "theme-picker" && (
4490
+ <ThemePicker
4491
+ currentTheme={themeSelection.committedTheme}
4492
+ onSelect={(value) => {
4493
+ if (themePreviewTimerRef.current) {
4494
+ clearTimeout(themePreviewTimerRef.current);
4495
+ themePreviewTimerRef.current = null;
4496
+ }
4497
+ setThemeSelection((currentTheme) => commitThemeSelection(currentTheme, value));
4498
+ setScreen("main");
4499
+ appendSystemEvent("Theme updated", `Visual theme switched to ${formatThemeLabel(value)}.`);
4500
+ if (value === "custom") {
4501
+ if (!customTheme) {
4502
+ setCustomTheme({ ...THEMES.purple });
4503
+ }
4504
+ appendSystemEvent(
4505
+ "Custom Theme",
4506
+ "Add a \"custom_theme\" object to ~/.codexa-settings.json with any of these keys: BG, PANEL, PANEL_ALT, PANEL_SOFT, BORDER, BORDER_ACTIVE, BORDER_SUBTLE, TEXT, MUTED, DIM, ACCENT, PROMPT, SUCCESS, WARNING, ERROR, INFO, STAR. Unset keys fall back to Midnight Purple defaults.",
4507
+ );
4508
+ }
4509
+ }}
4510
+ onHighlight={(value) => {
4511
+ if (themePreviewTimerRef.current) clearTimeout(themePreviewTimerRef.current);
4512
+ themePreviewTimerRef.current = setTimeout(() => {
4513
+ setThemeSelection((currentTheme) => previewThemeSelection(currentTheme, value));
4514
+ }, 120);
4515
+ }}
4516
+ onCancel={() => {
4517
+ if (themePreviewTimerRef.current) clearTimeout(themePreviewTimerRef.current);
4518
+ themePreviewTimerRef.current = null;
4519
+ setThemeSelection((currentTheme) => cancelThemeSelection(currentTheme));
4520
+ setScreen("main");
4521
+ }}
4522
+ />
4523
+ )}
4524
+
4525
+ {screen === "settings-panel" && (
4526
+ <SettingsPanel
4527
+ focusId={FOCUS_IDS.settingsPanel}
4528
+ settings={USER_SETTING_DEFINITIONS}
4529
+ values={currentUserSettings}
4530
+ onSave={(values) => saveSettingsFromPanel(values as UserSettingValues)}
4531
+ onCancel={() => setScreen("main")}
4532
+ />
4533
+ )}
4534
+
4535
+ {screen === "import-confirmation" && pendingImport && (
4536
+ <AttachmentImportPanel
4537
+ focusId={FOCUS_IDS.importConfirmationPanel}
4538
+ files={pendingImport.files}
4539
+ attachmentsDir={pendingImport.attachmentsDir}
4540
+ workspaceRoot={workspaceRoot}
4541
+ modelSupportsVision={activeRouteProvider?.capabilityProfile?.supportsVision ?? null}
4542
+ onConfirm={() => { void handleImportConfirm(); }}
4543
+ onCancel={handleImportCancel}
4544
+ />
4545
+ )}
4546
+ </>
4547
+ }
4548
+ mainPanel={null}
4549
+ mainPanelMode="viewport"
4550
+ composer={composerElement}
4551
+ composerRows={composerRows}
4552
+ panelHint={screen !== "main" && screen !== "model-picker" ? (
4553
+ <Box marginTop={1} paddingX={1}>
4554
+ <Text color={activeTheme.DIM}>Close the active panel with Esc to return to the composer.</Text>
4555
+ </Box>
4556
+ ) : null}
4557
+ />
4558
+ </ThemeProvider>
4559
+ );
4560
+ }
4561
+