@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,657 @@
1
+ import React, { memo, useEffect, useState, useMemo } from "react";
2
+ import { Box, Text } from "ink";
3
+ import type {
4
+ AssistantEvent,
5
+ RunEvent,
6
+ RunProgressBlock,
7
+ RunResponseSegment,
8
+ RunStreamItem,
9
+ RunToolActivity,
10
+ UIState,
11
+ UserPromptEvent,
12
+ } from "../session/types.js";
13
+ import { getAssistantContent, getResponseSegmentText, getRunPlanText } from "../session/types.js";
14
+ import { formatTerminalAnswerInline } from "./terminalAnswerFormat.js";
15
+ import { ActionRequiredBlock } from "./ActionRequiredBlock.js";
16
+ import { DashCard } from "./DashCard.js";
17
+ import { useTheme } from "./theme.js";
18
+ import { sanitizeTerminalOutput } from "../core/terminal/terminalSanitize.js";
19
+ import { wrapPlainText, wrapCommandText } from "./textLayout.js";
20
+ import { selectVisibleRunActivity } from "./runActivityView.js";
21
+ import type { RunFileActivity } from "../core/workspaceActivity.js";
22
+ import { RUN_OUTPUT_TRUNCATION_NOTICE } from "../session/chatLifecycle.js";
23
+ import { formatProgressBlockBodyLines } from "./progressEntries.js";
24
+ import { getUsableShellWidth, transcriptContentIndent } from "./layout.js";
25
+ import { MemoizedRenderMessage } from "./Markdown.js";
26
+ import {
27
+ sanitizeOutput,
28
+ sanitizeStreamChunk,
29
+ normalizeOutput,
30
+ classifyOutput,
31
+ formatForBox,
32
+ } from "./outputPipeline.js";
33
+ import { normalizeCommand, getFriendlyActionLabel } from "./commandNormalize.js";
34
+ import * as renderDebug from "../core/perf/renderDebug.js";
35
+ import { normalizePlanReviewMarkdown } from "../core/planStorage.js";
36
+
37
+ export type TurnOpacity = "active" | "recent" | "dim";
38
+
39
+ interface TurnGroupProps {
40
+ cols: number;
41
+ turnIndex: number;
42
+ user: UserPromptEvent;
43
+ run: RunEvent | null;
44
+ assistant: AssistantEvent | null;
45
+ opacity: TurnOpacity;
46
+ question: string | null;
47
+ runPhase: TurnRunPhase;
48
+ streamPreviewRows: number;
49
+ streamMode: "assistant-first";
50
+ verboseMode?: boolean;
51
+ workspaceRoot?: string | null;
52
+ }
53
+
54
+ function formatDuration(ms: number): string {
55
+ if (ms < 1000) return `${ms}ms`;
56
+ return `${(ms / 1000).toFixed(1)}s`;
57
+ }
58
+
59
+ // ─── User Input Card ─────────────────────────────────────────────────────────
60
+ // User prompt wrapped in a rounded DashCard border.
61
+
62
+ function UserInputCard({
63
+ prompt,
64
+ cols,
65
+ dim,
66
+ }: {
67
+ prompt: string;
68
+ cols: number;
69
+ dim: boolean;
70
+ }) {
71
+ const theme = useTheme();
72
+ const borderColor = theme.BORDER_SUBTLE;
73
+ const contentWidth = Math.max(1, cols - 7);
74
+ const lines = wrapPlainText(sanitizeTerminalOutput(prompt), contentWidth);
75
+
76
+ return (
77
+ <DashCard cols={cols} title="PROMPT" borderColor={borderColor}>
78
+ {lines.map((line, i) => (
79
+ <Text key={i} color={dim ? theme.DIM : theme.TEXT}>
80
+ {i === 0 ? "❯ " : " "}{line}
81
+ </Text>
82
+ ))}
83
+ </DashCard>
84
+ );
85
+ }
86
+
87
+ const MemoizedUserInputCard = memo(UserInputCard, (prev, next) => (
88
+ prev.prompt === next.prompt
89
+ && prev.cols === next.cols
90
+ && prev.dim === next.dim
91
+ ));
92
+
93
+ // ─── Impact Summary ──────────────────────────────────────────────────────────
94
+ // Compact file-change summary replacing FileScanCard + ActivityCard
95
+
96
+ function ImpactSummary({
97
+ run,
98
+ cols,
99
+ }: {
100
+ run: RunEvent;
101
+ cols: number;
102
+ }) {
103
+ const theme = useTheme();
104
+ const summary = run.activitySummary;
105
+ const hasFiles = run.touchedFileCount > 0;
106
+ const hasTools = run.toolActivities.length > 0;
107
+
108
+ if (!hasFiles && !hasTools) return null;
109
+
110
+ const contentWidth = Math.max(1, cols - 6);
111
+ const recentFiles = summary?.recent ?? run.activity.slice(-6);
112
+ const hasDeletes = (summary?.deleted ?? 0) > 0;
113
+
114
+ const opLabel = (op: string) => {
115
+ switch (op) {
116
+ case "created": return "CREATED ";
117
+ case "modified": return "MODIFIED";
118
+ case "deleted": return "DELETED ";
119
+ default: return op.toUpperCase().padEnd(8);
120
+ }
121
+ };
122
+
123
+ const opColor = (op: string) => {
124
+ switch (op) {
125
+ case "created": return theme.SUCCESS;
126
+ case "deleted": return theme.ERROR;
127
+ default: return theme.INFO;
128
+ }
129
+ };
130
+
131
+ return (
132
+ <Box flexDirection="column" width="100%" paddingX={1} marginTop={0}>
133
+ {hasDeletes && (
134
+ <Text color={theme.WARNING}>{"⚠ Destructive changes detected:"}</Text>
135
+ )}
136
+ {hasFiles && (
137
+ <>
138
+ <Text color={theme.DIM}>{" Changes:"}</Text>
139
+ {recentFiles.map((file: RunFileActivity, i: number) => {
140
+ const diffInfo = file.addedLines != null || file.removedLines != null
141
+ ? ` (+${file.addedLines ?? 0} -${file.removedLines ?? 0})`
142
+ : "";
143
+ return (
144
+ <Text key={i}>
145
+ <Text color={theme.DIM}>{" "}</Text>
146
+ <Text color={opColor(file.operation)}>{opLabel(file.operation)}</Text>
147
+ <Text color={theme.TEXT}>{" "}{file.path}</Text>
148
+ <Text color={theme.DIM}>{diffInfo}</Text>
149
+ </Text>
150
+ );
151
+ })}
152
+ </>
153
+ )}
154
+ <Text color={theme.DIM}>
155
+ {" "}
156
+ <Text color={theme.SUCCESS}>{"✔ "}</Text>
157
+ {run.touchedFileCount > 0 && `${run.touchedFileCount} file${run.touchedFileCount === 1 ? "" : "s"}`}
158
+ {hasTools && `${hasFiles ? " • " : ""}${run.toolActivities.length} action${run.toolActivities.length === 1 ? "" : "s"}`}
159
+ {run.durationMs != null && ` • ${formatDuration(run.durationMs)}`}
160
+ </Text>
161
+ </Box>
162
+ );
163
+ }
164
+
165
+ // ─── Verbose Cards (only shown in verbose mode) ──────────────────────────────
166
+
167
+ function FileScanCard({ run, cols }: { run: RunEvent; cols: number }) {
168
+ const theme = useTheme();
169
+ const { visible, hiddenCount } = selectVisibleRunActivity(run);
170
+ const badge = `${run.touchedFileCount} file${run.touchedFileCount === 1 ? "" : "s"}`;
171
+
172
+ return (
173
+ <DashCard cols={cols} title="Scanning workspace ..." rightBadge={badge}>
174
+ {hiddenCount > 0 && (
175
+ <Text color={theme.DIM}>{`... ${hiddenCount} more`}</Text>
176
+ )}
177
+ {visible.map((file, i) => (
178
+ <Text key={i} color={theme.SUCCESS}>
179
+ {"● "}<Text color={theme.TEXT}>{file.path}</Text>
180
+ </Text>
181
+ ))}
182
+ </DashCard>
183
+ );
184
+ }
185
+
186
+ const COMPACT_PROCESSING_BODY_LINE_CAP = 4;
187
+ const COMPACT_STREAMING_TAIL_CAP = 6;
188
+ const VISIBLE_THINKING_SOURCES = new Set(["reasoning", "todo"]);
189
+
190
+ // ─── Unified Event Stream Card ───────────────────────────────────────────────
191
+
192
+ type ResolvedStreamEvent =
193
+ | { kind: "thinking"; streamSeq: number; block: RunProgressBlock }
194
+ | { kind: "action"; streamSeq: number; tool: RunToolActivity }
195
+ | { kind: "response"; streamSeq: number; segment: RunResponseSegment }
196
+ | { kind: "plan"; streamSeq: number; planText: string; approved: boolean };
197
+
198
+ function resolveStreamEvents(
199
+ run: RunEvent,
200
+ assistant: AssistantEvent | null,
201
+ streaming: boolean,
202
+ ): ResolvedStreamEvent[] {
203
+ const blocksById = new Map<string, RunProgressBlock>();
204
+ for (const entry of run.progressEntries ?? []) {
205
+ for (const block of entry.blocks) blocksById.set(block.id, block);
206
+ }
207
+ const toolsById = new Map(run.toolActivities.map((tool) => [tool.id, tool] as const));
208
+ const segmentsById = new Map((run.responseSegments ?? []).map((seg) => [seg.id, seg] as const));
209
+
210
+ const items = (run.streamItems ?? []).slice().sort((a, b) => a.streamSeq - b.streamSeq);
211
+ const resolved: ResolvedStreamEvent[] = [];
212
+ for (const item of items) {
213
+ if (item.kind === "thinking") {
214
+ const block = blocksById.get(item.refId);
215
+ if (block && block.text.trim().length > 0 && !(run.status === "running" && block.status === "active")) {
216
+ resolved.push({ kind: "thinking", streamSeq: item.streamSeq, block });
217
+ }
218
+ } else if (item.kind === "action") {
219
+ const tool = toolsById.get(item.refId);
220
+ if (tool) resolved.push({ kind: "action", streamSeq: item.streamSeq, tool });
221
+ } else if (item.kind === "response") {
222
+ const segment = segmentsById.get(item.refId);
223
+ if (segment) resolved.push({ kind: "response", streamSeq: item.streamSeq, segment });
224
+ } else if (item.kind === "plan") {
225
+ const planText = run.plan?.id === item.refId
226
+ ? getRunPlanText(run.plan)
227
+ : run.approvedPlan ?? "";
228
+ if (planText.trim()) {
229
+ resolved.push({
230
+ kind: "plan",
231
+ streamSeq: item.streamSeq,
232
+ planText,
233
+ approved: Boolean(run.approvedPlan),
234
+ });
235
+ }
236
+ }
237
+ }
238
+
239
+ // Backward-compat fallback for older session data that predates streamItems.
240
+ // New runs always use the streamItems path above.
241
+ if (resolved.length === 0 && items.length === 0) {
242
+ let legacySeq = 0;
243
+ for (const entry of run.progressEntries ?? []) {
244
+ if (!VISIBLE_THINKING_SOURCES.has(entry.source)) continue;
245
+ for (const block of entry.blocks) {
246
+ if (!block.text.trim()) continue;
247
+ if (run.status === "running" && block.status === "active") continue;
248
+ legacySeq += 1;
249
+ resolved.push({ kind: "thinking", streamSeq: legacySeq, block });
250
+ }
251
+ }
252
+
253
+ for (const tool of run.toolActivities ?? []) {
254
+ legacySeq += 1;
255
+ resolved.push({ kind: "action", streamSeq: legacySeq, tool });
256
+ }
257
+
258
+ for (const segment of run.responseSegments ?? []) {
259
+ if (!getResponseSegmentText(segment).trim() && !streaming) continue;
260
+ legacySeq += 1;
261
+ resolved.push({ kind: "response", streamSeq: legacySeq, segment });
262
+ }
263
+ }
264
+
265
+ // First-render fallback: the assistant may have produced text before the
266
+ // run has received a stream item, especially with older persisted data.
267
+ if (resolved.length === 0 && (getAssistantContent(assistant).length > 0 || streaming)) {
268
+ const content = getAssistantContent(assistant);
269
+ resolved.push({
270
+ kind: "response",
271
+ streamSeq: 1,
272
+ segment: {
273
+ id: `synthetic-${run.id}`,
274
+ streamSeq: 1,
275
+ chunks: [content],
276
+ status: streaming ? "active" : "completed",
277
+ startedAt: run.startedAt,
278
+ },
279
+ });
280
+ }
281
+
282
+ return resolved;
283
+ }
284
+
285
+ function PlanPanel({
286
+ planText,
287
+ cols,
288
+ approved,
289
+ workspaceRoot,
290
+ }: {
291
+ planText: string;
292
+ cols: number;
293
+ approved: boolean;
294
+ workspaceRoot?: string | null;
295
+ }) {
296
+ const theme = useTheme();
297
+ const contentWidth = Math.max(1, getUsableShellWidth(cols, 4));
298
+
299
+ const formatted = useMemo(() => {
300
+ const normalized = normalizePlanReviewMarkdown(planText, workspaceRoot);
301
+ const classified = classifyOutput(normalized);
302
+ return formatForBox(classified, contentWidth);
303
+ }, [planText, contentWidth, workspaceRoot]);
304
+
305
+ return (
306
+ <DashCard
307
+ cols={cols}
308
+ title="Plan"
309
+ rightBadge={approved ? "approved" : undefined}
310
+ borderColor={theme.ACCENT}
311
+ titleColor={theme.TEXT}
312
+ badgeColor={theme.SUCCESS}
313
+ >
314
+ <MemoizedRenderMessage segments={formatted} width={contentWidth} brightHeadings />
315
+ </DashCard>
316
+ );
317
+ }
318
+
319
+ const MemoizedPlanPanel = memo(PlanPanel, (prev, next) => (
320
+ prev.planText === next.planText && prev.cols === next.cols && prev.approved === next.approved && prev.workspaceRoot === next.workspaceRoot
321
+ ));
322
+
323
+ function ActionEventCard({
324
+ cols,
325
+ tool,
326
+ opacity,
327
+ isLiveCursorTarget,
328
+ }: {
329
+ cols: number;
330
+ tool: RunToolActivity;
331
+ opacity: TurnOpacity;
332
+ isLiveCursorTarget: boolean;
333
+ }) {
334
+ const theme = useTheme();
335
+ const dim = opacity !== "active";
336
+ const actionNormalized = normalizeCommand(tool.command);
337
+ const actionLabel = getFriendlyActionLabel(actionNormalized);
338
+
339
+ const statusIcon = tool.status === "failed" ? "✕" : tool.status === "completed" ? "✔" : "▸";
340
+ const statusColor = tool.status === "failed" ? theme.ERROR : tool.status === "completed" ? theme.SUCCESS : theme.INFO;
341
+ const borderColor = dim ? theme.BORDER_SUBTLE : tool.status === "running" ? theme.BORDER_ACTIVE : theme.BORDER_SUBTLE;
342
+ const detailText = isLiveCursorTarget && tool.status === "running"
343
+ ? "▌"
344
+ : tool.summary?.trim() ? tool.summary : " ";
345
+ const detailColor = isLiveCursorTarget && tool.status === "running" ? theme.ACCENT : theme.MUTED;
346
+ const duration = tool.completedAt != null
347
+ ? formatDuration(tool.completedAt - tool.startedAt)
348
+ : null;
349
+
350
+ const commandBodyWidth = Math.max(1, cols - 6);
351
+ const commandLines = wrapCommandText(actionNormalized, commandBodyWidth);
352
+
353
+ return (
354
+ <DashCard cols={cols} title="action" rightBadge={duration || undefined} borderColor={borderColor}>
355
+ {actionLabel ? (
356
+ <>
357
+ <Box>
358
+ <Text color={statusColor}>{statusIcon + " "}</Text>
359
+ <Text color={dim ? theme.DIM : theme.TEXT}>{actionLabel}</Text>
360
+ </Box>
361
+ {commandLines.map((line, i) => (
362
+ <Text key={i} color={theme.MUTED}>{" "}{line || " "}</Text>
363
+ ))}
364
+ </>
365
+ ) : (
366
+ <>
367
+ {commandLines.map((line, i) => (
368
+ <Box key={i}>
369
+ <Text color={i === 0 ? statusColor : undefined}>{i === 0 ? statusIcon + " " : " "}</Text>
370
+ <Text color={dim ? theme.DIM : theme.TEXT}>{line || " "}</Text>
371
+ </Box>
372
+ ))}
373
+ <Text color={theme.MUTED}>{" "}</Text>
374
+ </>
375
+ )}
376
+ <Text color={detailColor}>{" "}{detailText}</Text>
377
+ </DashCard>
378
+ );
379
+ }
380
+
381
+ const MemoizedActionEventCard = memo(ActionEventCard, (prev, next) =>
382
+ prev.tool.id === next.tool.id &&
383
+ prev.tool.status === next.tool.status &&
384
+ prev.tool.command === next.tool.command &&
385
+ prev.tool.completedAt === next.tool.completedAt &&
386
+ prev.tool.summary === next.tool.summary &&
387
+ prev.cols === next.cols &&
388
+ prev.opacity === next.opacity &&
389
+ prev.isLiveCursorTarget === next.isLiveCursorTarget
390
+ );
391
+
392
+ function CodexThinkingBlock({
393
+ block,
394
+ cols,
395
+ isLiveCursorTarget,
396
+ verboseMode,
397
+ }: {
398
+ block: RunProgressBlock;
399
+ cols: number;
400
+ isLiveCursorTarget: boolean;
401
+ verboseMode: boolean;
402
+ }) {
403
+ const theme = useTheme();
404
+ const contentWidth = Math.max(1, getUsableShellWidth(cols, transcriptContentIndent + 1));
405
+
406
+ return (
407
+ <Box flexDirection="column" width="100%" paddingLeft={transcriptContentIndent} paddingRight={1}>
408
+ <Text color={theme.MUTED} bold>Codexa</Text>
409
+ {formatProgressBlockBodyLines(block.text, contentWidth)
410
+ .slice(0, verboseMode ? undefined : COMPACT_PROCESSING_BODY_LINE_CAP)
411
+ .map((line, i) => (
412
+ <Text key={i} color={theme.DIM}>{line || " "}</Text>
413
+ ))}
414
+ {isLiveCursorTarget && block.status === "active" && (
415
+ <Text color={theme.ACCENT}>▌</Text>
416
+ )}
417
+ </Box>
418
+ );
419
+ }
420
+
421
+ function CodexResponseBlock({
422
+ run,
423
+ segment,
424
+ cols,
425
+ streaming,
426
+ isLast,
427
+ isLiveCursorTarget,
428
+ verboseMode,
429
+ }: {
430
+ run: RunEvent;
431
+ segment: RunResponseSegment;
432
+ cols: number;
433
+ streaming: boolean;
434
+ isLast: boolean;
435
+ isLiveCursorTarget: boolean;
436
+ verboseMode: boolean;
437
+ }) {
438
+ const theme = useTheme();
439
+ const contentWidth = Math.max(1, getUsableShellWidth(cols, transcriptContentIndent + 1));
440
+
441
+ const formatted = useMemo(() => {
442
+ const raw = formatTerminalAnswerInline(getResponseSegmentText(segment));
443
+ const sanitized = segment.status === "active"
444
+ ? sanitizeStreamChunk(raw)
445
+ : sanitizeOutput(raw);
446
+ const normalized = normalizeOutput(sanitized);
447
+ const classified = classifyOutput(normalized);
448
+ return formatForBox(classified, contentWidth);
449
+ }, [contentWidth, segment]);
450
+
451
+ const segmentStreaming = segment.status === "active";
452
+ const showTail = !segmentStreaming && !verboseMode && formatted.length > COMPACT_STREAMING_TAIL_CAP;
453
+
454
+ return (
455
+ <Box flexDirection="column" width="100%" paddingLeft={transcriptContentIndent} paddingRight={1}>
456
+ <Text color={theme.MUTED} bold>Codexa</Text>
457
+ {run.status === "failed" && !streaming && isLast && (
458
+ <Box flexDirection="column">
459
+ {wrapPlainText(sanitizeTerminalOutput(run.errorMessage ?? run.summary), contentWidth).map((row, i) => (
460
+ <Text key={i} color={theme.ERROR}>{i === 0 ? `✕ ${row}` : ` ${row}`}</Text>
461
+ ))}
462
+ </Box>
463
+ )}
464
+ <MemoizedRenderMessage
465
+ segments={showTail ? formatted.slice(-COMPACT_STREAMING_TAIL_CAP) : formatted}
466
+ width={contentWidth}
467
+ />
468
+ {isLiveCursorTarget && segmentStreaming && (
469
+ <Text color={theme.ACCENT}>▌</Text>
470
+ )}
471
+ </Box>
472
+ );
473
+ }
474
+
475
+ const StreamEventList = memo(function StreamEventList({
476
+ cols,
477
+ run,
478
+ assistant,
479
+ runPhase,
480
+ opacity,
481
+ verboseMode,
482
+ workspaceRoot,
483
+ }: {
484
+ cols: number;
485
+ run: RunEvent;
486
+ assistant: AssistantEvent | null;
487
+ runPhase: TurnRunPhase;
488
+ opacity: TurnOpacity;
489
+ verboseMode: boolean;
490
+ workspaceRoot?: string | null;
491
+ }) {
492
+ const streaming = runPhase === "streaming";
493
+ const events = useMemo(
494
+ () => resolveStreamEvents(run, assistant, streaming),
495
+ [run, assistant, streaming],
496
+ );
497
+
498
+ return (
499
+ <Box flexDirection="column" width="100%">
500
+ {events.map((event, index) => {
501
+ const isLast = index === events.length - 1;
502
+ const isLiveCursorTarget = run.status === "running" && isLast;
503
+
504
+ return (
505
+ <Box key={`${event.kind}-${event.streamSeq}`} flexDirection="column" marginTop={index > 0 ? 1 : 0}>
506
+ {event.kind === "thinking" && (
507
+ <CodexThinkingBlock
508
+ block={event.block}
509
+ cols={cols}
510
+ isLiveCursorTarget={isLiveCursorTarget}
511
+ verboseMode={verboseMode}
512
+ />
513
+ )}
514
+ {event.kind === "action" && (
515
+ <MemoizedActionEventCard
516
+ cols={cols}
517
+ tool={event.tool}
518
+ opacity={opacity}
519
+ isLiveCursorTarget={isLiveCursorTarget}
520
+ />
521
+ )}
522
+ {event.kind === "response" && (
523
+ <CodexResponseBlock
524
+ run={run}
525
+ segment={event.segment}
526
+ cols={cols}
527
+ streaming={streaming}
528
+ isLast={isLast}
529
+ isLiveCursorTarget={isLiveCursorTarget}
530
+ verboseMode={verboseMode}
531
+ />
532
+ )}
533
+ {event.kind === "plan" && (
534
+ <MemoizedPlanPanel
535
+ planText={event.planText}
536
+ cols={cols}
537
+ approved={event.approved}
538
+ workspaceRoot={workspaceRoot}
539
+ />
540
+ )}
541
+ </Box>
542
+ );
543
+ })}
544
+
545
+ {run.status !== "running" && !verboseMode && (
546
+ <Box marginTop={1}>
547
+ <ImpactSummary run={run} cols={cols} />
548
+ </Box>
549
+ )}
550
+ </Box>
551
+ );
552
+ }, (prev, next) => (
553
+ prev.cols === next.cols
554
+ && prev.run === next.run
555
+ && prev.assistant === next.assistant
556
+ && prev.runPhase === next.runPhase
557
+ && prev.opacity === next.opacity
558
+ && prev.verboseMode === next.verboseMode
559
+ && prev.workspaceRoot === next.workspaceRoot
560
+ ));
561
+
562
+ // ─── TurnGroup ───────────────────────────────────────────────────────────────
563
+
564
+ export function TurnGroup({
565
+ cols,
566
+ turnIndex,
567
+ user,
568
+ run,
569
+ assistant,
570
+ opacity,
571
+ question,
572
+ runPhase,
573
+ verboseMode = false,
574
+ workspaceRoot,
575
+ }: TurnGroupProps) {
576
+ return (
577
+ <Box flexDirection="column" width="100%" marginBottom={1}>
578
+ <MemoizedUserInputCard
579
+ prompt={user.prompt}
580
+ cols={cols}
581
+ dim={opacity === "dim"}
582
+ />
583
+
584
+ {run && (
585
+ <Box marginTop={1}>
586
+ <StreamEventList
587
+ cols={cols}
588
+ run={run}
589
+ assistant={assistant}
590
+ runPhase={runPhase}
591
+ opacity={opacity}
592
+ verboseMode={verboseMode}
593
+ workspaceRoot={workspaceRoot}
594
+ />
595
+ </Box>
596
+ )}
597
+
598
+ {run && run.status !== "running" && verboseMode && (
599
+ <>
600
+ {run.touchedFileCount > 0 && <FileScanCard run={run} cols={cols} />}
601
+ {/* ActivityCard is skipped because actions are now in the unified stream */}
602
+ </>
603
+ )}
604
+
605
+ {question && <ActionRequiredBlock cols={cols} turnIndex={turnIndex} question={question} />}
606
+ </Box>
607
+ );
608
+ }
609
+
610
+ // Memoized wrapper to prevent re-renders of finalized turns
611
+ export const MemoizedTurnGroup = memo(TurnGroup, (prev, next) => {
612
+ return (
613
+ prev.cols === next.cols &&
614
+ prev.turnIndex === next.turnIndex &&
615
+ prev.opacity === next.opacity &&
616
+ prev.question === next.question &&
617
+ prev.runPhase === next.runPhase &&
618
+ prev.streamPreviewRows === next.streamPreviewRows &&
619
+ prev.streamMode === next.streamMode &&
620
+ prev.verboseMode === next.verboseMode &&
621
+ prev.user === next.user &&
622
+ prev.run === next.run &&
623
+ prev.assistant === next.assistant &&
624
+ prev.workspaceRoot === next.workspaceRoot
625
+ );
626
+ });
627
+
628
+ export type TurnRunPhase = "none" | "thinking" | "streaming" | "final";
629
+
630
+ export function resolveTurnRunPhase(
631
+ run: RunEvent | null,
632
+ assistant: AssistantEvent | null,
633
+ uiState: UIState,
634
+ turnId: number,
635
+ ): TurnRunPhase {
636
+ if (!run) return "none";
637
+ if (run.status !== "running") return "final";
638
+
639
+ if (uiState.kind === "RESPONDING" && uiState.turnId === turnId) {
640
+ return "streaming";
641
+ }
642
+
643
+ if (uiState.kind === "ANSWER_VISIBLE" && uiState.turnId === turnId) {
644
+ return "final";
645
+ }
646
+
647
+ if (uiState.kind === "THINKING" && uiState.turnId === turnId) {
648
+ return "thinking";
649
+ }
650
+
651
+ // Defensive fallback to prevent blank/stale turn cards during rapid state churn.
652
+ if (getAssistantContent(assistant).trim()) {
653
+ return "streaming";
654
+ }
655
+
656
+ return "thinking";
657
+ }
@@ -0,0 +1,30 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ BUSY_STATUS_FRAME_MS,
5
+ BUSY_STATUS_FRAMES,
6
+ getBusyStatusFrame,
7
+ isAnimatedBusyState,
8
+ } from "./busyStatusAnimation.js";
9
+
10
+ test("busy status frames advance in a fixed-width dot slot", () => {
11
+ assert.deepEqual(
12
+ [0, 1, 2, 3, 4].map(getBusyStatusFrame),
13
+ [" . ", " .. ", " ...", " . ", " .. "],
14
+ );
15
+ assert.ok(BUSY_STATUS_FRAMES.every((frame) => frame.length === BUSY_STATUS_FRAMES[0]!.length));
16
+ });
17
+
18
+ test("busy status animation uses a low redraw cadence", () => {
19
+ assert.equal(BUSY_STATUS_FRAME_MS, 900);
20
+ });
21
+
22
+ test("busy animation runs only for active work states", () => {
23
+ assert.equal(isAnimatedBusyState("THINKING"), true);
24
+ assert.equal(isAnimatedBusyState("RESPONDING"), true);
25
+ assert.equal(isAnimatedBusyState("SHELL_RUNNING"), true);
26
+ assert.equal(isAnimatedBusyState("IDLE"), false);
27
+ assert.equal(isAnimatedBusyState("ANSWER_VISIBLE"), false);
28
+ assert.equal(isAnimatedBusyState("ERROR"), false);
29
+ assert.equal(isAnimatedBusyState("AWAITING_USER_ACTION"), false);
30
+ });
@@ -0,0 +1,11 @@
1
+ export const BUSY_STATUS_FRAME_MS = 900;
2
+ export const BUSY_STATUS_FRAMES = [" . ", " .. ", " ..."] as const;
3
+
4
+ export function getBusyStatusFrame(frameIndex: number): string {
5
+ const safeIndex = Math.max(0, Math.floor(frameIndex));
6
+ return BUSY_STATUS_FRAMES[safeIndex % BUSY_STATUS_FRAMES.length]!;
7
+ }
8
+
9
+ export function isAnimatedBusyState(kind: string): boolean {
10
+ return kind === "THINKING" || kind === "RESPONDING" || kind === "SHELL_RUNNING";
11
+ }