@golba98/codexa 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +320 -0
  2. package/bin/codexa.js +445 -0
  3. package/package.json +45 -0
  4. package/scripts/audit-codexa-capabilities.mjs +466 -0
  5. package/scripts/smoke-terminal-bench.mjs +35 -0
  6. package/src/app.tsx +4561 -0
  7. package/src/appRenderStability.test.ts +131 -0
  8. package/src/commands/handler.test.ts +643 -0
  9. package/src/commands/handler.ts +875 -0
  10. package/src/config/launchArgs.test.ts +158 -0
  11. package/src/config/launchArgs.ts +186 -0
  12. package/src/config/layeredConfig.test.ts +143 -0
  13. package/src/config/layeredConfig.ts +836 -0
  14. package/src/config/persistence.test.ts +110 -0
  15. package/src/config/persistence.ts +311 -0
  16. package/src/config/runtimeConfig.test.ts +218 -0
  17. package/src/config/runtimeConfig.ts +554 -0
  18. package/src/config/settings.test.ts +155 -0
  19. package/src/config/settings.ts +401 -0
  20. package/src/config/toml-serialize.ts +98 -0
  21. package/src/config/trustStore.test.ts +29 -0
  22. package/src/config/trustStore.ts +68 -0
  23. package/src/core/attachments.test.ts +155 -0
  24. package/src/core/attachments.ts +71 -0
  25. package/src/core/auth/codexAuth.test.ts +68 -0
  26. package/src/core/auth/codexAuth.ts +359 -0
  27. package/src/core/cleanupFastFail.test.ts +76 -0
  28. package/src/core/cleanupFastFail.ts +67 -0
  29. package/src/core/clipboard.ts +24 -0
  30. package/src/core/codex.ts +124 -0
  31. package/src/core/codexExecArgs.test.ts +195 -0
  32. package/src/core/codexExecArgs.ts +152 -0
  33. package/src/core/codexLaunch.test.ts +205 -0
  34. package/src/core/codexLaunch.ts +162 -0
  35. package/src/core/codexPrompt.test.ts +252 -0
  36. package/src/core/codexPrompt.ts +428 -0
  37. package/src/core/executables/claudeExecutable.ts +63 -0
  38. package/src/core/executables/codexExecutable.test.ts +212 -0
  39. package/src/core/executables/codexExecutable.ts +159 -0
  40. package/src/core/executables/executableResolver.test.ts +129 -0
  41. package/src/core/executables/executableResolver.ts +138 -0
  42. package/src/core/executables/geminiExecutable.test.ts +116 -0
  43. package/src/core/executables/geminiExecutable.ts +78 -0
  44. package/src/core/executables/pathSanityScan.test.ts +47 -0
  45. package/src/core/githubDiagnostics.test.ts +92 -0
  46. package/src/core/githubDiagnostics.ts +222 -0
  47. package/src/core/hollowResponseFormat.test.ts +58 -0
  48. package/src/core/hollowResponseFormat.ts +39 -0
  49. package/src/core/inputDebug.ts +51 -0
  50. package/src/core/launchContext.test.ts +157 -0
  51. package/src/core/launchContext.ts +266 -0
  52. package/src/core/models/codexCapabilities.test.ts +45 -0
  53. package/src/core/models/codexCapabilities.ts +95 -0
  54. package/src/core/models/codexModelCapabilities.test.ts +246 -0
  55. package/src/core/models/codexModelCapabilities.ts +571 -0
  56. package/src/core/models/modelSpecs.test.ts +283 -0
  57. package/src/core/models/modelSpecs.ts +300 -0
  58. package/src/core/perf/profiler.ts +125 -0
  59. package/src/core/perf/renderDebug.test.ts +230 -0
  60. package/src/core/perf/renderDebug.ts +373 -0
  61. package/src/core/planStorage.test.ts +143 -0
  62. package/src/core/planStorage.ts +141 -0
  63. package/src/core/process/CommandRunner.test.ts +105 -0
  64. package/src/core/process/CommandRunner.ts +269 -0
  65. package/src/core/process/processValidation.ts +101 -0
  66. package/src/core/projectInstructions.test.ts +50 -0
  67. package/src/core/projectInstructions.ts +54 -0
  68. package/src/core/providerLauncher/launcher.test.ts +238 -0
  69. package/src/core/providerLauncher/launcher.ts +203 -0
  70. package/src/core/providerLauncher/registry.test.ts +324 -0
  71. package/src/core/providerLauncher/registry.ts +253 -0
  72. package/src/core/providerLauncher/types.ts +84 -0
  73. package/src/core/providerLauncher/workspaceConfig.test.ts +638 -0
  74. package/src/core/providerLauncher/workspaceConfig.ts +407 -0
  75. package/src/core/providerRuntime/anthropic.test.ts +1120 -0
  76. package/src/core/providerRuntime/anthropic.ts +576 -0
  77. package/src/core/providerRuntime/capabilityProfile.test.ts +311 -0
  78. package/src/core/providerRuntime/capabilityProfile.ts +288 -0
  79. package/src/core/providerRuntime/claudeCodeDiscovery.ts +446 -0
  80. package/src/core/providerRuntime/contextMetadata.test.ts +468 -0
  81. package/src/core/providerRuntime/contextMetadata.ts +409 -0
  82. package/src/core/providerRuntime/gemini.test.ts +437 -0
  83. package/src/core/providerRuntime/gemini.ts +784 -0
  84. package/src/core/providerRuntime/lmstudio.test.ts +168 -0
  85. package/src/core/providerRuntime/lmstudio.ts +118 -0
  86. package/src/core/providerRuntime/local.test.ts +787 -0
  87. package/src/core/providerRuntime/local.ts +754 -0
  88. package/src/core/providerRuntime/models.ts +150 -0
  89. package/src/core/providerRuntime/reasoning.ts +17 -0
  90. package/src/core/providerRuntime/registry.test.ts +233 -0
  91. package/src/core/providerRuntime/registry.ts +203 -0
  92. package/src/core/providerRuntime/types.ts +103 -0
  93. package/src/core/providers/codexJsonStream.test.ts +148 -0
  94. package/src/core/providers/codexJsonStream.ts +305 -0
  95. package/src/core/providers/codexSubprocess.test.ts +68 -0
  96. package/src/core/providers/codexSubprocess.ts +372 -0
  97. package/src/core/providers/codexTranscript.test.ts +284 -0
  98. package/src/core/providers/codexTranscript.ts +695 -0
  99. package/src/core/providers/openaiNative.ts +13 -0
  100. package/src/core/providers/registry.ts +21 -0
  101. package/src/core/providers/types.ts +59 -0
  102. package/src/core/terminal/terminalCapabilities.test.ts +93 -0
  103. package/src/core/terminal/terminalCapabilities.ts +100 -0
  104. package/src/core/terminal/terminalControl.test.ts +75 -0
  105. package/src/core/terminal/terminalControl.ts +147 -0
  106. package/src/core/terminal/terminalSanitize.test.ts +22 -0
  107. package/src/core/terminal/terminalSanitize.ts +147 -0
  108. package/src/core/terminal/terminalSelection.test.ts +42 -0
  109. package/src/core/terminal/terminalSelection.ts +66 -0
  110. package/src/core/terminal/terminalTitle.test.ts +328 -0
  111. package/src/core/terminal/terminalTitle.ts +483 -0
  112. package/src/core/workspaceActivity.test.ts +163 -0
  113. package/src/core/workspaceActivity.ts +380 -0
  114. package/src/core/workspaceGuard.test.ts +151 -0
  115. package/src/core/workspaceGuard.ts +288 -0
  116. package/src/core/workspaceRoot.test.ts +23 -0
  117. package/src/core/workspaceRoot.ts +47 -0
  118. package/src/exec.test.ts +13 -0
  119. package/src/exec.ts +72 -0
  120. package/src/headless/execArgs.test.ts +147 -0
  121. package/src/headless/execArgs.ts +294 -0
  122. package/src/headless/execRunner.test.ts +434 -0
  123. package/src/headless/execRunner.ts +304 -0
  124. package/src/index.test.tsx +618 -0
  125. package/src/index.tsx +296 -0
  126. package/src/session/appSession.test.ts +897 -0
  127. package/src/session/appSession.ts +761 -0
  128. package/src/session/chatLifecycle.test.ts +64 -0
  129. package/src/session/chatLifecycle.ts +951 -0
  130. package/src/session/liveRenderScheduler.test.ts +201 -0
  131. package/src/session/liveRenderScheduler.ts +214 -0
  132. package/src/session/planFlow.test.ts +103 -0
  133. package/src/session/planFlow.ts +149 -0
  134. package/src/session/planTranscript.test.ts +65 -0
  135. package/src/session/planTranscript.ts +15 -0
  136. package/src/session/promptRunSchedule.test.ts +36 -0
  137. package/src/session/promptRunSchedule.ts +26 -0
  138. package/src/session/types.ts +228 -0
  139. package/src/test/runtimeTestUtils.ts +14 -0
  140. package/src/types/react-dom.d.ts +3 -0
  141. package/src/ui/ActionRequiredBlock.tsx +38 -0
  142. package/src/ui/ActivityBars.tsx +68 -0
  143. package/src/ui/ActivityIndicator.test.tsx +58 -0
  144. package/src/ui/ActivityIndicator.tsx +58 -0
  145. package/src/ui/AgentBlock.test.ts +6 -0
  146. package/src/ui/AgentBlock.tsx +130 -0
  147. package/src/ui/AnimatedStatusText.test.ts +16 -0
  148. package/src/ui/AnimatedStatusText.tsx +69 -0
  149. package/src/ui/AppShell.test.tsx +1739 -0
  150. package/src/ui/AppShell.tsx +698 -0
  151. package/src/ui/AttachmentImportPanel.test.tsx +204 -0
  152. package/src/ui/AttachmentImportPanel.tsx +98 -0
  153. package/src/ui/AuthPanel.tsx +113 -0
  154. package/src/ui/BackendPicker.tsx +28 -0
  155. package/src/ui/BottomComposer.test.ts +674 -0
  156. package/src/ui/BottomComposer.tsx +1028 -0
  157. package/src/ui/CodexLogo.tsx +55 -0
  158. package/src/ui/DashCard.tsx +82 -0
  159. package/src/ui/Markdown.test.ts +157 -0
  160. package/src/ui/Markdown.tsx +310 -0
  161. package/src/ui/ModePicker.tsx +27 -0
  162. package/src/ui/ModelPicker.tsx +31 -0
  163. package/src/ui/ModelPickerProviderScope.test.tsx +411 -0
  164. package/src/ui/ModelPickerScreen.test.tsx +99 -0
  165. package/src/ui/ModelPickerScreen.tsx +416 -0
  166. package/src/ui/ModelPickerState.test.tsx +151 -0
  167. package/src/ui/ModelReasoningPicker.test.tsx +447 -0
  168. package/src/ui/ModelReasoningPicker.tsx +458 -0
  169. package/src/ui/Panel.tsx +51 -0
  170. package/src/ui/PermissionsPanel.tsx +78 -0
  171. package/src/ui/PlanActionPicker.tsx +119 -0
  172. package/src/ui/PlanReviewPanel.test.tsx +267 -0
  173. package/src/ui/PlanReviewPanel.tsx +212 -0
  174. package/src/ui/PromptCardBorder.test.tsx +161 -0
  175. package/src/ui/ProviderPicker.test.tsx +289 -0
  176. package/src/ui/ProviderPicker.tsx +321 -0
  177. package/src/ui/ProviderShortcut.test.tsx +143 -0
  178. package/src/ui/ReasoningPicker.tsx +46 -0
  179. package/src/ui/RunFooter.tsx +65 -0
  180. package/src/ui/SelectionPanel.tsx +67 -0
  181. package/src/ui/SettingsPanel.test.tsx +233 -0
  182. package/src/ui/SettingsPanel.tsx +156 -0
  183. package/src/ui/Spinner.tsx +25 -0
  184. package/src/ui/StaticIntroItem.tsx +54 -0
  185. package/src/ui/StaticTranscriptItem.tsx +56 -0
  186. package/src/ui/TextEntryPanel.tsx +139 -0
  187. package/src/ui/ThemePicker.tsx +31 -0
  188. package/src/ui/ThinkingBlock.tsx +100 -0
  189. package/src/ui/Timeline.test.ts +2067 -0
  190. package/src/ui/Timeline.tsx +1472 -0
  191. package/src/ui/TimelineNavigation.test.tsx +201 -0
  192. package/src/ui/TopHeader.test.tsx +239 -0
  193. package/src/ui/TopHeader.tsx +257 -0
  194. package/src/ui/TurnGroup.test.tsx +365 -0
  195. package/src/ui/TurnGroup.tsx +657 -0
  196. package/src/ui/busyStatusAnimation.test.ts +30 -0
  197. package/src/ui/busyStatusAnimation.ts +11 -0
  198. package/src/ui/commandNormalize.test.ts +142 -0
  199. package/src/ui/commandNormalize.ts +66 -0
  200. package/src/ui/diffRenderer.test.ts +102 -0
  201. package/src/ui/diffRenderer.ts +116 -0
  202. package/src/ui/focus.ts +61 -0
  203. package/src/ui/focusFlow.test.tsx +1098 -0
  204. package/src/ui/inputBuffer.test.ts +151 -0
  205. package/src/ui/inputBuffer.ts +203 -0
  206. package/src/ui/layout.test.ts +145 -0
  207. package/src/ui/layout.ts +287 -0
  208. package/src/ui/modeDisplay.test.ts +42 -0
  209. package/src/ui/modeDisplay.ts +52 -0
  210. package/src/ui/outputPipeline.ts +64 -0
  211. package/src/ui/progressEntries.ts +156 -0
  212. package/src/ui/runActivityView.test.ts +89 -0
  213. package/src/ui/runActivityView.ts +37 -0
  214. package/src/ui/runLifecycleView.test.tsx +237 -0
  215. package/src/ui/slashCommands.ts +41 -0
  216. package/src/ui/statusRenderIsolation.test.tsx +654 -0
  217. package/src/ui/terminalAnswerFormat.test.ts +19 -0
  218. package/src/ui/terminalAnswerFormat.ts +128 -0
  219. package/src/ui/textLayout.test.ts +18 -0
  220. package/src/ui/textLayout.ts +338 -0
  221. package/src/ui/theme.tsx +395 -0
  222. package/src/ui/themeFlow.test.ts +53 -0
  223. package/src/ui/themeFlow.ts +41 -0
  224. package/src/ui/timelineMeasure.ts +3088 -0
  225. package/src/ui/timelineMeasureCache.test.ts +986 -0
  226. package/src/ui/useThrottledValue.ts +31 -0
@@ -0,0 +1,761 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ import type { BackendProgressUpdate } from "../core/providers/types.js";
3
+ import type { AssistantEvent, ExternalCliStatus, RunEvent, ShellEvent, TimelineEvent, UIState, UserPromptEvent } from "./types.js";
4
+ import { getAssistantContent, getRunPlanText } from "./types.js";
5
+ import {
6
+ appendRunActivity,
7
+ appendRunPlanChunk,
8
+ appendRunResponseChunk,
9
+ appendRunThinking,
10
+ appendStaticEvents,
11
+ cancelRunEvent,
12
+ completeRunEvent,
13
+ failRunEvent,
14
+ finalizePlanBlock,
15
+ finalizeResponseSegments,
16
+ markResponseSegmentsCompleted,
17
+ reduceUIState,
18
+ upsertRunToolActivity,
19
+ type UIStateAction,
20
+ } from "./chatLifecycle.js";
21
+ import type { RunFileActivity } from "../core/workspaceActivity.js";
22
+ import type { RunToolActivity } from "./types.js";
23
+ import type { LiveRenderUpdate } from "./liveRenderScheduler.js";
24
+ import * as renderDebug from "../core/perf/renderDebug.js";
25
+
26
+ // ─── Types ───────────────────────────────────────────────────────────────────
27
+
28
+ export interface SessionState {
29
+ staticEvents: TimelineEvent[];
30
+ activeEvents: TimelineEvent[];
31
+ uiState: UIState;
32
+ externalCliStatus: ExternalCliStatus;
33
+ inputValue: string;
34
+ cursor: number;
35
+ history: string[];
36
+ historyIndex: number;
37
+ clearCount: number;
38
+ clearEpoch: number; // Incremented on each /clear to suppress stale async events
39
+ }
40
+
41
+ export type SessionAction =
42
+ | { type: "APPEND_STATIC_EVENT"; event: TimelineEvent }
43
+ | { type: "APPEND_STATIC_EVENTS"; events: TimelineEvent[] }
44
+ | { type: "SET_INPUT"; value: string; cursor?: number }
45
+ | { type: "RESET_INPUT" }
46
+ | { type: "PUSH_HISTORY"; value: string }
47
+ | { type: "SUBMIT_PROMPT_RUN"; historyValue?: string; events: TimelineEvent[]; turnId: number; runId: number }
48
+ | { type: "HISTORY_UP" }
49
+ | { type: "HISTORY_DOWN" }
50
+ | { type: "CLEAR_TRANSCRIPT" }
51
+ | { type: "SET_ACTIVE_EVENTS"; events: TimelineEvent[] }
52
+ | { type: "RUN_APPEND_ACTIVITY"; runId: number; activity: RunFileActivity[] }
53
+ | { type: "RUN_APPLY_PROGRESS_UPDATES"; runId: number; updates: BackendProgressUpdate[] }
54
+ | { type: "RUN_UPSERT_TOOL_ACTIVITY"; runId: number; activity: RunToolActivity }
55
+ | {
56
+ type: "RUN_APPEND_PLAN_DELTA";
57
+ turnId: number;
58
+ runId: number;
59
+ chunk: string;
60
+ }
61
+ | {
62
+ type: "RUN_APPEND_ASSISTANT_DELTA";
63
+ turnId: number;
64
+ runId: number;
65
+ chunk: string;
66
+ eventFactory: () => AssistantEvent;
67
+ }
68
+ | {
69
+ type: "RUN_MARK_FINAL_ANSWER_OBSERVED";
70
+ runId: number;
71
+ turnId: number;
72
+ response?: string;
73
+ }
74
+ | {
75
+ type: "RUN_APPLY_LIVE_UPDATES";
76
+ turnId: number;
77
+ runId: number;
78
+ updates: LiveRenderUpdate[];
79
+ assistantEventFactory: (chunk: string) => AssistantEvent;
80
+ }
81
+ | {
82
+ type: "FINALIZE_RUN";
83
+ runId: number;
84
+ turnId: number;
85
+ status: "completed" | "failed" | "canceled";
86
+ message?: string;
87
+ response?: string;
88
+ durationMs?: number;
89
+ responsePresentation?: "assistant" | "plan";
90
+ question?: string | null;
91
+ assistantFactory: () => AssistantEvent;
92
+ }
93
+ | { type: "FINALIZE_SHELL"; shellId: number; finalEvent: ShellEvent }
94
+ | { type: "UPDATE_SHELL_LINES"; shellId: number; stream: "stdout" | "stderr"; lines: string[] }
95
+ | { type: "REMOVE_ACTIVE_RUNTIME"; runId: number; turnId?: number | null }
96
+ | { type: "UI_ACTION"; action: UIStateAction }
97
+ | { type: "SET_EXTERNAL_CLI_STATUS"; status: ExternalCliStatus };
98
+
99
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
100
+
101
+ export function createInitialSessionState(): SessionState {
102
+ return {
103
+ staticEvents: [],
104
+ activeEvents: [],
105
+ uiState: { kind: "IDLE" },
106
+ externalCliStatus: "idle",
107
+ inputValue: "",
108
+ cursor: 0,
109
+ history: [],
110
+ historyIndex: -1,
111
+ clearCount: 0,
112
+ clearEpoch: 0,
113
+ };
114
+ }
115
+
116
+ function updateShellLines(event: ShellEvent, action: Extract<SessionAction, { type: "UPDATE_SHELL_LINES" }>): ShellEvent {
117
+ if (action.stream === "stdout") {
118
+ return { ...event, lines: [...event.lines, ...action.lines] };
119
+ }
120
+ return { ...event, stderrLines: [...event.stderrLines, ...action.lines] };
121
+ }
122
+
123
+ export function findUserPrompt(events: TimelineEvent[], turnId: number): UserPromptEvent | null {
124
+ const event = events.find((entry): entry is UserPromptEvent => entry.type === "user" && entry.turnId === turnId);
125
+ return event ?? null;
126
+ }
127
+
128
+ function reconcileAssistantContent(
129
+ streamed: string | undefined,
130
+ response: string | undefined,
131
+ status: "completed" | "failed" | "canceled",
132
+ ): string {
133
+ if (status !== "completed") return streamed?.trim() ? streamed : "";
134
+ if (!response?.trim()) return streamed ?? "";
135
+ if (!streamed?.trim()) return response;
136
+
137
+ const norm = (s: string) => s.replace(/\s+/g, " ").trim();
138
+ const sNorm = norm(streamed);
139
+ const rNorm = norm(response);
140
+
141
+ if (sNorm === rNorm) return streamed; // exact match → keep streamed formatting
142
+ // streamed is a prefix of response, or they differ — authoritative response wins either way
143
+ return response;
144
+ }
145
+
146
+ function isAnimatedLifecycleKind(kind: UIState["kind"]): boolean {
147
+ return kind === "THINKING" || kind === "RESPONDING" || kind === "SHELL_RUNNING";
148
+ }
149
+
150
+ function stateMatchesTurn(state: UIState, turnId: number): boolean {
151
+ return "turnId" in state && state.turnId === turnId;
152
+ }
153
+
154
+ function getUIActionTurnId(action: UIStateAction): number | null {
155
+ return "turnId" in action ? action.turnId : null;
156
+ }
157
+
158
+ function getUIStateTurnId(state: UIState): number | null {
159
+ return "turnId" in state ? state.turnId : null;
160
+ }
161
+
162
+ function traceUITransition(params: {
163
+ previous: UIState;
164
+ next: UIState;
165
+ reason: string;
166
+ runId?: number;
167
+ turnId?: number | null;
168
+ }): void {
169
+ if (params.previous === params.next) return;
170
+
171
+ renderDebug.traceLifecycleTransition({
172
+ runId: params.runId,
173
+ turnId: params.turnId ?? getUIStateTurnId(params.next) ?? getUIStateTurnId(params.previous),
174
+ prevKind: params.previous.kind,
175
+ nextKind: params.next.kind,
176
+ reason: params.reason,
177
+ composerEnabled: !isAnimatedLifecycleKind(params.next.kind),
178
+ animationActive: isAnimatedLifecycleKind(params.next.kind),
179
+ ts: Date.now(),
180
+ });
181
+ }
182
+
183
+ function reduceTracedUIState(
184
+ state: UIState,
185
+ action: UIStateAction,
186
+ options: { reason?: string; runId?: number } = {},
187
+ ): UIState {
188
+ const next = reduceUIState(state, action);
189
+ traceUITransition({
190
+ previous: state,
191
+ next,
192
+ reason: options.reason ?? action.type,
193
+ runId: options.runId,
194
+ turnId: getUIActionTurnId(action),
195
+ });
196
+ return next;
197
+ }
198
+
199
+ function terminalActionForFinalize(action: Extract<SessionAction, { type: "FINALIZE_RUN" }>): UIStateAction {
200
+ if (action.status === "completed") {
201
+ return action.question
202
+ ? { type: "AWAITING_USER_ACTION", turnId: action.turnId, question: action.question }
203
+ : { type: "RUN_COMPLETED", turnId: action.turnId };
204
+ }
205
+
206
+ if (action.status === "failed") {
207
+ return { type: "RUN_FAILED", turnId: action.turnId, message: action.message ?? "Run failed" };
208
+ }
209
+
210
+ return { type: "RUN_CANCELED", turnId: action.turnId };
211
+ }
212
+
213
+ function enforceFinalizePostCondition(
214
+ previous: UIState,
215
+ reduced: UIState,
216
+ action: Extract<SessionAction, { type: "FINALIZE_RUN" }>,
217
+ ): UIState {
218
+ if (!stateMatchesTurn(previous, action.turnId)) {
219
+ return reduced;
220
+ }
221
+
222
+ const forced: UIState = action.status === "completed" && action.question
223
+ ? { kind: "AWAITING_USER_ACTION", turnId: action.turnId, question: action.question }
224
+ : action.status === "failed"
225
+ ? { kind: "ERROR", turnId: action.turnId, message: action.message ?? "Run failed" }
226
+ : { kind: "IDLE" };
227
+
228
+ if (
229
+ reduced.kind === forced.kind
230
+ && getUIStateTurnId(reduced) === getUIStateTurnId(forced)
231
+ && (!("message" in forced) || ("message" in reduced && reduced.message === forced.message))
232
+ && (!("question" in forced) || ("question" in reduced && reduced.question === forced.question))
233
+ ) {
234
+ return reduced;
235
+ }
236
+
237
+ traceUITransition({
238
+ previous: reduced,
239
+ next: forced,
240
+ reason: "FINALIZE_RUN_POST_CONDITION",
241
+ runId: action.runId,
242
+ turnId: action.turnId,
243
+ });
244
+ return forced;
245
+ }
246
+
247
+ function reduceFinalizeUIState(
248
+ state: UIState,
249
+ action: Extract<SessionAction, { type: "FINALIZE_RUN" }>,
250
+ ): UIState {
251
+ const reduced = reduceTracedUIState(
252
+ state,
253
+ terminalActionForFinalize(action),
254
+ { reason: `FINALIZE_RUN:${action.status}`, runId: action.runId },
255
+ );
256
+ return enforceFinalizePostCondition(state, reduced, action);
257
+ }
258
+
259
+ function preserveUIStateIdentity(previous: UIState, next: UIState): UIState {
260
+ if (previous === next) return previous;
261
+ if (previous.kind !== next.kind) return next;
262
+ if ("message" in previous && "message" in next && previous.message !== next.message) return next;
263
+ if ("question" in previous && "question" in next && previous.question !== next.question) return next;
264
+ if ("turnId" in previous && "turnId" in next && previous.turnId !== next.turnId) return next;
265
+ return previous;
266
+ }
267
+
268
+ function eventCount(state: SessionState): number {
269
+ return state.staticEvents.length + state.activeEvents.length;
270
+ }
271
+
272
+ // ─── Reducer ─────────────────────────────────────────────────────────────────
273
+
274
+ export function reduceSessionState(state: SessionState, action: SessionAction): SessionState {
275
+ switch (action.type) {
276
+ case "APPEND_STATIC_EVENT":
277
+ return { ...state, staticEvents: appendStaticEvents(state.staticEvents, [action.event]) };
278
+ case "APPEND_STATIC_EVENTS":
279
+ return { ...state, staticEvents: appendStaticEvents(state.staticEvents, action.events) };
280
+ case "SET_INPUT":
281
+ return {
282
+ ...state,
283
+ inputValue: action.value,
284
+ cursor: Math.max(0, Math.min(action.cursor ?? action.value.length, action.value.length)),
285
+ };
286
+ case "RESET_INPUT":
287
+ return { ...state, inputValue: "", cursor: 0, historyIndex: -1 };
288
+ case "PUSH_HISTORY":
289
+ return {
290
+ ...state,
291
+ history: [action.value, ...state.history.filter((entry) => entry !== action.value)].slice(0, 50),
292
+ historyIndex: -1,
293
+ };
294
+ case "SUBMIT_PROMPT_RUN": {
295
+ const nextHistory = action.historyValue
296
+ ? [action.historyValue, ...state.history.filter((entry) => entry !== action.historyValue)].slice(0, 50)
297
+ : state.history;
298
+ return {
299
+ ...state,
300
+ activeEvents: action.events,
301
+ uiState: reduceTracedUIState(
302
+ state.uiState,
303
+ { type: "PROMPT_RUN_STARTED", turnId: action.turnId },
304
+ { reason: "SUBMIT_PROMPT_RUN", runId: action.runId },
305
+ ),
306
+ inputValue: "",
307
+ cursor: 0,
308
+ history: nextHistory,
309
+ historyIndex: -1,
310
+ };
311
+ }
312
+ case "HISTORY_UP": {
313
+ if (state.history.length === 0) return state;
314
+ const nextIndex = Math.min(state.historyIndex + 1, state.history.length - 1);
315
+ const nextValue = state.history[nextIndex] ?? "";
316
+ return { ...state, historyIndex: nextIndex, inputValue: nextValue, cursor: nextValue.length };
317
+ }
318
+ case "HISTORY_DOWN": {
319
+ if (state.historyIndex <= 0) {
320
+ return { ...state, historyIndex: -1, inputValue: "", cursor: 0 };
321
+ }
322
+ const nextIndex = state.historyIndex - 1;
323
+ const nextValue = state.history[nextIndex] ?? "";
324
+ return { ...state, historyIndex: nextIndex, inputValue: nextValue, cursor: nextValue.length };
325
+ }
326
+ case "CLEAR_TRANSCRIPT":
327
+ renderDebug.traceEvent("transcript", "clear", {
328
+ previousStaticEventsLength: state.staticEvents.length,
329
+ previousActiveEventsLength: state.activeEvents.length,
330
+ uiStateKind: state.uiState.kind,
331
+ });
332
+ return {
333
+ ...state,
334
+ staticEvents: [],
335
+ activeEvents: [],
336
+ uiState: { kind: "IDLE" },
337
+ clearCount: state.clearCount + 1,
338
+ clearEpoch: state.clearEpoch + 1,
339
+ };
340
+ case "SET_ACTIVE_EVENTS":
341
+ renderDebug.traceEvent("transcript", "activeEventsReplace", {
342
+ previousLength: state.activeEvents.length,
343
+ nextLength: action.events.length,
344
+ uiStateKind: state.uiState.kind,
345
+ preventedEmptyIntermediate: action.events.length === 0 && state.activeEvents.length > 0 && isAnimatedLifecycleKind(state.uiState.kind),
346
+ });
347
+ if (action.events.length === 0 && state.activeEvents.length > 0 && isAnimatedLifecycleKind(state.uiState.kind)) {
348
+ renderDebug.traceBlankFrame("Session", {
349
+ reason: "prevented-empty-active-events-replacement",
350
+ previousActiveEventsLength: state.activeEvents.length,
351
+ uiStateKind: state.uiState.kind,
352
+ });
353
+ return state;
354
+ }
355
+ return { ...state, activeEvents: action.events };
356
+ case "RUN_APPEND_ACTIVITY": {
357
+ if (!state.activeEvents.some((event) => event.id === action.runId && event.type === "run")) {
358
+ return state;
359
+ }
360
+ return {
361
+ ...state,
362
+ activeEvents: state.activeEvents.map((event) =>
363
+ event.id === action.runId && event.type === "run"
364
+ ? appendRunActivity(event as RunEvent, action.activity)
365
+ : event
366
+ ),
367
+ };
368
+ }
369
+ case "RUN_APPLY_PROGRESS_UPDATES": {
370
+ if (!state.activeEvents.some((event) => event.id === action.runId && event.type === "run")) {
371
+ return state;
372
+ }
373
+ return {
374
+ ...state,
375
+ activeEvents: state.activeEvents.map((event) =>
376
+ event.id === action.runId && event.type === "run"
377
+ ? appendRunThinking(event as RunEvent, action.updates)
378
+ : event
379
+ ),
380
+ };
381
+ }
382
+ case "RUN_UPSERT_TOOL_ACTIVITY": {
383
+ if (!state.activeEvents.some((event) => event.id === action.runId && event.type === "run")) {
384
+ return state;
385
+ }
386
+ renderDebug.traceEvent("action", "upsert", {
387
+ runId: action.runId,
388
+ actionId: action.activity.id,
389
+ status: action.activity.status,
390
+ });
391
+ return {
392
+ ...state,
393
+ activeEvents: state.activeEvents.map((event) =>
394
+ event.id === action.runId && event.type === "run"
395
+ ? upsertRunToolActivity(event as RunEvent, action.activity)
396
+ : event
397
+ ),
398
+ };
399
+ }
400
+ case "RUN_APPEND_PLAN_DELTA": {
401
+ const existingRun = state.activeEvents.find(
402
+ (event): event is RunEvent =>
403
+ event.type === "run" && event.id === action.runId && event.turnId === action.turnId,
404
+ );
405
+ if (!existingRun) {
406
+ return state;
407
+ }
408
+
409
+ return {
410
+ ...state,
411
+ activeEvents: state.activeEvents.map((event) =>
412
+ event.id === action.runId && event.type === "run"
413
+ ? appendRunPlanChunk(event as RunEvent, action.chunk)
414
+ : event
415
+ ),
416
+ uiState: reduceTracedUIState(
417
+ state.uiState,
418
+ { type: "FIRST_ASSISTANT_DELTA", turnId: action.turnId },
419
+ { runId: action.runId },
420
+ ),
421
+ };
422
+ }
423
+ case "RUN_APPEND_ASSISTANT_DELTA": {
424
+ const existingRun = state.activeEvents.find(
425
+ (event): event is RunEvent =>
426
+ event.type === "run" && event.id === action.runId && event.turnId === action.turnId,
427
+ );
428
+ if (!existingRun) {
429
+ return state;
430
+ }
431
+
432
+ const existingAssistant = state.activeEvents.find(
433
+ (event): event is AssistantEvent => event.type === "assistant" && event.turnId === action.turnId,
434
+ );
435
+
436
+ const updateRun = (event: TimelineEvent): TimelineEvent => (
437
+ event.id === action.runId && event.type === "run"
438
+ ? appendRunResponseChunk(event as RunEvent, action.chunk)
439
+ : event
440
+ );
441
+
442
+ if (existingAssistant) {
443
+ return {
444
+ ...state,
445
+ activeEvents: state.activeEvents.map((event) => {
446
+ if (event.type === "assistant" && event.turnId === action.turnId) {
447
+ return { ...event, contentChunks: [...(event as AssistantEvent).contentChunks, action.chunk] };
448
+ }
449
+ return updateRun(event);
450
+ }),
451
+ uiState: reduceTracedUIState(
452
+ state.uiState,
453
+ { type: "FIRST_ASSISTANT_DELTA", turnId: action.turnId },
454
+ { runId: action.runId },
455
+ ),
456
+ };
457
+ }
458
+
459
+ return {
460
+ ...state,
461
+ activeEvents: [...state.activeEvents.map(updateRun), action.eventFactory()],
462
+ uiState: reduceTracedUIState(
463
+ state.uiState,
464
+ { type: "FIRST_ASSISTANT_DELTA", turnId: action.turnId },
465
+ { runId: action.runId },
466
+ ),
467
+ };
468
+ }
469
+ case "RUN_APPLY_LIVE_UPDATES": {
470
+ const existingRun = state.activeEvents.find(
471
+ (event): event is RunEvent =>
472
+ event.type === "run" && event.id === action.runId && event.turnId === action.turnId,
473
+ );
474
+ if (!existingRun) {
475
+ return state;
476
+ }
477
+
478
+ const existingAssistant = state.activeEvents.find(
479
+ (event): event is AssistantEvent => event.type === "assistant" && event.turnId === action.turnId,
480
+ );
481
+ let nextRun = existingRun;
482
+ let nextAssistant: AssistantEvent | null = existingAssistant ?? null;
483
+ let assistantCreated = false;
484
+ let sawAssistantOrPlanDelta = false;
485
+
486
+ for (const update of action.updates) {
487
+ if (update.type === "activity") {
488
+ nextRun = appendRunActivity(nextRun, update.activity);
489
+ } else if (update.type === "progress") {
490
+ nextRun = appendRunThinking(nextRun, [update.update]);
491
+ } else if (update.type === "tool") {
492
+ renderDebug.traceEvent("action", "upsert", {
493
+ runId: action.runId,
494
+ actionId: update.activity.id,
495
+ status: update.activity.status,
496
+ });
497
+ nextRun = upsertRunToolActivity(nextRun, update.activity);
498
+ } else if (update.type === "plan") {
499
+ sawAssistantOrPlanDelta = true;
500
+ nextRun = appendRunPlanChunk(nextRun, update.chunk);
501
+ } else {
502
+ sawAssistantOrPlanDelta = true;
503
+ nextRun = appendRunResponseChunk(nextRun, update.chunk);
504
+ if (nextAssistant) {
505
+ nextAssistant = {
506
+ ...nextAssistant,
507
+ contentChunks: [...nextAssistant.contentChunks, update.chunk],
508
+ };
509
+ } else {
510
+ nextAssistant = action.assistantEventFactory(update.chunk);
511
+ assistantCreated = true;
512
+ }
513
+ }
514
+ }
515
+
516
+ const activeEvents = state.activeEvents.map((event) => {
517
+ if (event.type === "run" && event.id === action.runId) {
518
+ return nextRun;
519
+ }
520
+ if (nextAssistant && event.type === "assistant" && event.turnId === action.turnId) {
521
+ return nextAssistant;
522
+ }
523
+ return event;
524
+ });
525
+
526
+ const withAssistant = assistantCreated && nextAssistant
527
+ ? [...activeEvents, nextAssistant]
528
+ : activeEvents;
529
+
530
+ return {
531
+ ...state,
532
+ activeEvents: withAssistant,
533
+ uiState: sawAssistantOrPlanDelta
534
+ ? reduceTracedUIState(
535
+ state.uiState,
536
+ { type: "FIRST_ASSISTANT_DELTA", turnId: action.turnId },
537
+ { runId: action.runId },
538
+ )
539
+ : state.uiState,
540
+ };
541
+ }
542
+ case "RUN_MARK_FINAL_ANSWER_OBSERVED": {
543
+ const existingRun = state.activeEvents.find(
544
+ (event): event is RunEvent =>
545
+ event.type === "run" && event.id === action.runId && event.turnId === action.turnId,
546
+ );
547
+ if (!existingRun) {
548
+ return state;
549
+ }
550
+
551
+ return {
552
+ ...state,
553
+ activeEvents: state.activeEvents.map((event) =>
554
+ event.id === action.runId && event.type === "run"
555
+ ? markResponseSegmentsCompleted(event as RunEvent, action.response)
556
+ : event
557
+ ),
558
+ uiState: reduceTracedUIState(
559
+ state.uiState,
560
+ { type: "FINAL_ANSWER_VISIBLE", turnId: action.turnId },
561
+ { runId: action.runId },
562
+ ),
563
+ };
564
+ }
565
+ case "FINALIZE_RUN": {
566
+ const userEvent = state.activeEvents.find(
567
+ (event): event is UserPromptEvent => event.type === "user" && event.turnId === action.turnId,
568
+ );
569
+ const runEvent = state.activeEvents.find(
570
+ (event): event is RunEvent => event.type === "run" && event.id === action.runId,
571
+ );
572
+ const assistantEvent = state.activeEvents.find(
573
+ (event): event is AssistantEvent => event.type === "assistant" && event.turnId === action.turnId,
574
+ );
575
+
576
+ const remainingEvents = state.activeEvents.filter((event) =>
577
+ !(event.type === "run" && event.id === action.runId)
578
+ && !(event.type === "assistant" && event.turnId === action.turnId)
579
+ && !(event.type === "user" && event.turnId === action.turnId),
580
+ );
581
+
582
+ if (!runEvent) {
583
+ renderDebug.traceEvent("transcript", "finalizeWithoutRunEvent", {
584
+ runId: action.runId,
585
+ turnId: action.turnId,
586
+ remainingEventsLength: remainingEvents.length,
587
+ });
588
+ return {
589
+ ...state,
590
+ activeEvents: remainingEvents,
591
+ uiState: reduceFinalizeUIState(state.uiState, action),
592
+ };
593
+ }
594
+
595
+ const baseFinalizedRun =
596
+ action.status === "completed"
597
+ ? completeRunEvent(runEvent, action.durationMs)
598
+ : action.status === "failed"
599
+ ? failRunEvent(runEvent, action.message ?? "Run failed", action.message ?? "Run failed", action.durationMs)
600
+ : cancelRunEvent(runEvent, action.durationMs);
601
+
602
+ const planPresentation = action.responsePresentation === "plan";
603
+ if (planPresentation) {
604
+ const planContent = reconcileAssistantContent(
605
+ getRunPlanText(runEvent.plan),
606
+ action.response,
607
+ action.status,
608
+ );
609
+ const finalizedRun = finalizePlanBlock(baseFinalizedRun, planContent);
610
+
611
+ const additions: TimelineEvent[] = [];
612
+ if (userEvent) additions.push(userEvent);
613
+ additions.push(finalizedRun);
614
+
615
+ return {
616
+ ...state,
617
+ staticEvents: appendStaticEvents(state.staticEvents, additions),
618
+ activeEvents: remainingEvents,
619
+ uiState: reduceFinalizeUIState(state.uiState, action),
620
+ };
621
+ }
622
+
623
+ const streamedContent = getAssistantContent(assistantEvent);
624
+ const assistantContent = reconcileAssistantContent(
625
+ streamedContent,
626
+ action.response,
627
+ action.status,
628
+ );
629
+
630
+ // Reconcile response segments with the authoritative final text.
631
+ // If the authoritative text differs from streamed (or no segments exist),
632
+ // rewrite/synthesize the trailing segment so the rendered timeline
633
+ // shows the final answer in chronological position.
634
+ const trimmedFinal = assistantContent.trim();
635
+ const streamedTrim = streamedContent.trim();
636
+ const overrideSegmentText = trimmedFinal && trimmedFinal !== streamedTrim
637
+ ? assistantContent
638
+ : undefined;
639
+ const finalizedRun = finalizeResponseSegments(baseFinalizedRun, overrideSegmentText);
640
+
641
+ const additions: TimelineEvent[] = [];
642
+ if (userEvent) additions.push(userEvent);
643
+ additions.push(finalizedRun);
644
+ if (assistantContent.trim()) {
645
+ additions.push(
646
+ assistantEvent
647
+ ? { ...assistantEvent, content: assistantContent, contentChunks: [] }
648
+ : action.assistantFactory(),
649
+ );
650
+ }
651
+
652
+ return {
653
+ ...state,
654
+ staticEvents: appendStaticEvents(state.staticEvents, additions),
655
+ activeEvents: remainingEvents,
656
+ uiState: reduceFinalizeUIState(state.uiState, action),
657
+ };
658
+ }
659
+ case "FINALIZE_SHELL":
660
+ return {
661
+ ...state,
662
+ staticEvents: appendStaticEvents(state.staticEvents, [action.finalEvent]),
663
+ activeEvents: state.activeEvents.filter((event) => !(event.type === "shell" && event.id === action.shellId)),
664
+ uiState: reduceTracedUIState(state.uiState, { type: "SHELL_FINISHED", shellId: action.shellId }),
665
+ };
666
+ case "UPDATE_SHELL_LINES":
667
+ return {
668
+ ...state,
669
+ activeEvents: state.activeEvents.map((event) =>
670
+ event.id === action.shellId && event.type === "shell"
671
+ ? updateShellLines(event as ShellEvent, action)
672
+ : event
673
+ ),
674
+ };
675
+ case "REMOVE_ACTIVE_RUNTIME":
676
+ return {
677
+ ...state,
678
+ activeEvents: state.activeEvents.filter((event) =>
679
+ !(event.type === "run" && event.id === action.runId)
680
+ && !(event.type === "assistant" && action.turnId != null && event.turnId === action.turnId)
681
+ && !(event.type === "shell" && event.id === action.runId)
682
+ && !(event.type === "user" && action.turnId != null && event.turnId === action.turnId),
683
+ ),
684
+ };
685
+ case "UI_ACTION":
686
+ return { ...state, uiState: reduceTracedUIState(state.uiState, action.action) };
687
+ case "SET_EXTERNAL_CLI_STATUS":
688
+ if (state.externalCliStatus === action.status) return state;
689
+ return { ...state, externalCliStatus: action.status };
690
+ default:
691
+ return state;
692
+ }
693
+ }
694
+
695
+ // ─── Hook ────────────────────────────────────────────────────────────────────
696
+
697
+ export function useAppSessionState() {
698
+ const [state, setState] = useState<SessionState>(createInitialSessionState);
699
+ const queueRef = useRef<SessionAction[]>([]);
700
+ const scheduledRef = useRef(false);
701
+
702
+ const dispatch = useCallback((action: SessionAction) => {
703
+ queueRef.current.push(action);
704
+ if (scheduledRef.current) return;
705
+
706
+ scheduledRef.current = true;
707
+ queueMicrotask(() => {
708
+ scheduledRef.current = false;
709
+ const queued = queueRef.current.splice(0, queueRef.current.length);
710
+ if (queued.length === 0) return;
711
+
712
+ renderDebug.traceTimelineUpdate({
713
+ queuedActions: queued.length,
714
+ actionTypes: queued.map((item) => item.type),
715
+ });
716
+ setState((current) => {
717
+ const next = queued.reduce(reduceSessionState, current);
718
+ const previousCount = eventCount(current);
719
+ const nextCount = eventCount(next);
720
+ if (
721
+ previousCount !== nextCount
722
+ || current.staticEvents.length !== next.staticEvents.length
723
+ || current.activeEvents.length !== next.activeEvents.length
724
+ ) {
725
+ renderDebug.traceEvent("transcript", "eventArrayLengthChange", {
726
+ actionTypes: queued.map((item) => item.type),
727
+ previousTotalLength: previousCount,
728
+ nextTotalLength: nextCount,
729
+ previousStaticEventsLength: current.staticEvents.length,
730
+ nextStaticEventsLength: next.staticEvents.length,
731
+ previousActiveEventsLength: current.activeEvents.length,
732
+ nextActiveEventsLength: next.activeEvents.length,
733
+ previousUiStateKind: current.uiState.kind,
734
+ nextUiStateKind: next.uiState.kind,
735
+ });
736
+ }
737
+ if (previousCount > 0 && nextCount === 0) {
738
+ renderDebug.traceBlankFrame("Session", {
739
+ reason: "transcript-length-dropped-to-zero",
740
+ actionTypes: queued.map((item) => item.type),
741
+ previousTotalLength: previousCount,
742
+ previousStaticEventsLength: current.staticEvents.length,
743
+ previousActiveEventsLength: current.activeEvents.length,
744
+ nextUiStateKind: next.uiState.kind,
745
+ });
746
+ }
747
+ // Preserve uiState identity when nothing meaningful changed,
748
+ // so AppShell's memo check (prev.uiState === next.uiState) can bail out.
749
+ if (next !== current && next.uiState !== current.uiState) {
750
+ const preserved = preserveUIStateIdentity(current.uiState, next.uiState);
751
+ if (preserved === current.uiState) {
752
+ return { ...next, uiState: preserved };
753
+ }
754
+ }
755
+ return next;
756
+ });
757
+ });
758
+ }, []);
759
+
760
+ return { state, dispatch };
761
+ }