@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,3088 @@
1
+ import type {
2
+ RunEvent,
3
+ ShellEvent,
4
+ RunProgressBlock,
5
+ RunResponseSegment,
6
+ RunToolActivity,
7
+ } from "../session/types.js";
8
+ import * as renderDebug from "../core/perf/renderDebug.js";
9
+ import { getAssistantContent, getResponseSegmentText, getRunPlanText } from "../session/types.js";
10
+ import { normalizeCommand, getFriendlyActionLabel } from "./commandNormalize.js";
11
+ import { formatTerminalAnswerInline } from "./terminalAnswerFormat.js";
12
+ import { RUN_OUTPUT_TRUNCATION_NOTICE } from "../session/chatLifecycle.js";
13
+ import { sanitizeTerminalLines, sanitizeTerminalOutput } from "../core/terminal/terminalSanitize.js";
14
+ import { clampVisualText, transcriptContentIndent } from "./layout.js";
15
+ import type { Segment } from "./Markdown.js";
16
+ import { classifyOutput, formatForBox, normalizeOutput, sanitizeOutput, sanitizeStreamChunk } from "./outputPipeline.js";
17
+ import { maybeRenderDiff, type DiffRenderLineType } from "./diffRenderer.js";
18
+ import {
19
+ formatProgressBlockBodyLines,
20
+ getProgressUpdateCount,
21
+ selectVisibleProgressBlocks,
22
+ type VisibleProgressBlock,
23
+ } from "./progressEntries.js";
24
+ import { selectVisibleRunActivity } from "./runActivityView.js";
25
+ import { getTextUnits, getTextWidth, wrapPlainText, wrapCommandText, splitTextAtColumn } from "./textLayout.js";
26
+ import type { RenderTimelineItem } from "./Timeline.js";
27
+ import { normalizePlanReviewMarkdown } from "../core/planStorage.js";
28
+
29
+ // ─── Exported types ───────────────────────────────────────────────────────────
30
+
31
+ export type TimelineTone =
32
+ | "text"
33
+ | "dim"
34
+ | "muted"
35
+ | "accent"
36
+ | "info"
37
+ | "error"
38
+ | "warning"
39
+ | "success"
40
+ | "borderSubtle"
41
+ | "borderActive"
42
+ | "panel"
43
+ | "star";
44
+
45
+ export interface TimelineRowSpan {
46
+ text: string;
47
+ tone?: TimelineTone;
48
+ bold?: boolean;
49
+ backgroundTone?: TimelineTone;
50
+ }
51
+
52
+ export interface TimelineRow {
53
+ key: string;
54
+ spans: TimelineRowSpan[];
55
+ }
56
+
57
+ export interface BuiltTimelineItem {
58
+ key: string;
59
+ rows: TimelineRow[];
60
+ rowCount: number;
61
+ }
62
+
63
+ export interface TimelineSnapshot {
64
+ items: BuiltTimelineItem[];
65
+ rows: TimelineRow[];
66
+ totalRows: number;
67
+ itemCount: number;
68
+ }
69
+
70
+ export interface StableTimelineSnapshot {
71
+ snapshot: TimelineSnapshot;
72
+ frozenRows: TimelineRow[];
73
+ liveRows: TimelineRow[];
74
+ }
75
+
76
+ export interface NativeTranscriptRowItem {
77
+ key: string;
78
+ rows: TimelineRow[];
79
+ }
80
+
81
+ export interface NativeTranscriptParts {
82
+ staticItems: NativeTranscriptRowItem[];
83
+ liveRows: TimelineRow[];
84
+ }
85
+
86
+ // ─── Internal types & constants ──────────────────────────────────────────────
87
+
88
+ interface MarkdownInlinePart {
89
+ kind: "text" | "code" | "bold";
90
+ text: string;
91
+ }
92
+
93
+ const MAX_SHELL_FAILURE_EXCERPT_LINES = 3;
94
+ const MAX_VISIBLE_PROGRESS_ENTRIES = 3;
95
+ const COMPACT_PROCESSING_BODY_LINE_CAP = 4;
96
+ const COMPACT_STREAMING_TAIL_CAP = 6;
97
+ const VISIBLE_THINKING_SOURCES = new Set(["reasoning", "todo"]);
98
+ const INTRO_WORDMARK = [
99
+ " ██████╗ ██████╗ ██████╗ ███████╗██╗ ██╗ █████╗ ",
100
+ "██╔════╝██╔═══██╗██╔══██╗██╔════╝╚██╗██╔╝██╔══██╗",
101
+ "██║ ██║ ██║██║ ██║█████╗ ╚███╔╝ ███████║",
102
+ "██║ ██║ ██║██║ ██║██╔══╝ ██╔██╗ ██╔══██║",
103
+ "╚██████╗╚██████╔╝██████╔╝███████╗██╔╝ ██╗██║ ██║",
104
+ " ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝",
105
+ ];
106
+
107
+ // Matches sentence-ending punctuation followed (optionally after whitespace) by
108
+ // a capital letter starting a new word. Requires [A-Z] to be followed by [a-z]
109
+ // OR to be a standalone "I" (I'm / I've / I ) so abbreviations like U.S.A.
110
+ // and Python class names like foo.BarClass are left alone — the lookahead
111
+ // fails when the capital is followed by another uppercase or punctuation.
112
+ const SENTENCE_WALL_SPLIT_RE = /([.!?])\s*(?=(?:I(?:['\u2019]|\s)|[A-Z][a-z]))/g;
113
+
114
+ function splitSentenceWall(text: string): string {
115
+ if (!text) return text;
116
+ // Preserve code fences: only transform outside ``` regions.
117
+ const parts = text.split("```");
118
+ return parts
119
+ .map((part, index) => (index % 2 === 0 ? part.replace(SENTENCE_WALL_SPLIT_RE, "$1\n\n") : part))
120
+ .join("```");
121
+ }
122
+
123
+ // ─── Span & row primitives ────────────────────────────────────────────────────
124
+
125
+ function createSpan(
126
+ text: string,
127
+ tone?: TimelineTone,
128
+ options: Pick<TimelineRowSpan, "bold" | "backgroundTone"> = {},
129
+ ): TimelineRowSpan {
130
+ return {
131
+ text,
132
+ tone,
133
+ bold: options.bold,
134
+ backgroundTone: options.backgroundTone,
135
+ };
136
+ }
137
+
138
+ function spansEqual(left: TimelineRowSpan | undefined, right: TimelineRowSpan): boolean {
139
+ return left?.tone === right.tone
140
+ && left?.bold === right.bold
141
+ && left?.backgroundTone === right.backgroundTone;
142
+ }
143
+
144
+ function appendSpan(target: TimelineRowSpan[], span: TimelineRowSpan) {
145
+ if (!span.text) return;
146
+ const previous = target[target.length - 1];
147
+ if (previous && spansEqual(previous, span)) {
148
+ previous.text += span.text;
149
+ return;
150
+ }
151
+ target.push({ ...span });
152
+ }
153
+
154
+ function cloneSpan(span: TimelineRowSpan, text = span.text): TimelineRowSpan {
155
+ return {
156
+ text,
157
+ tone: span.tone,
158
+ bold: span.bold,
159
+ backgroundTone: span.backgroundTone,
160
+ };
161
+ }
162
+
163
+ function getSpansWidth(spans: TimelineRowSpan[]): number {
164
+ return spans.reduce((width, span) => width + getTextWidth(span.text), 0);
165
+ }
166
+
167
+ function padSpansToWidth(spans: TimelineRowSpan[], width: number): TimelineRowSpan[] {
168
+ const safeWidth = Math.max(0, width);
169
+ const next = spans.map((span) => ({ ...span }));
170
+ const currentWidth = getSpansWidth(next);
171
+ if (currentWidth < safeWidth) {
172
+ appendSpan(next, createSpan(" ".repeat(safeWidth - currentWidth)));
173
+ }
174
+ return next;
175
+ }
176
+
177
+ const ROW_CONTENT_CACHE_LIMIT = 2500;
178
+ const _rowContentCache = new Map<string, TimelineRow>();
179
+
180
+ function spanCacheToken(span: TimelineRowSpan): string {
181
+ return [
182
+ span.text,
183
+ span.tone ?? "",
184
+ span.backgroundTone ?? "",
185
+ span.bold ? "1" : "0",
186
+ ].join("\u001f");
187
+ }
188
+
189
+ function rememberRow(cacheKey: string, row: TimelineRow): TimelineRow {
190
+ if (_rowContentCache.has(cacheKey)) {
191
+ _rowContentCache.delete(cacheKey);
192
+ }
193
+ _rowContentCache.set(cacheKey, row);
194
+ if (_rowContentCache.size > ROW_CONTENT_CACHE_LIMIT) {
195
+ const oldestKey = _rowContentCache.keys().next().value;
196
+ if (oldestKey !== undefined) {
197
+ _rowContentCache.delete(oldestKey);
198
+ }
199
+ }
200
+ return row;
201
+ }
202
+
203
+ function createRow(key: string, spans: TimelineRowSpan[], width: number): TimelineRow {
204
+ const paddedSpans = padSpansToWidth(spans, width);
205
+ const cacheKey = `${key}:${width}:${paddedSpans.map(spanCacheToken).join("\u001e")}`;
206
+ const cached = _rowContentCache.get(cacheKey);
207
+ if (cached) {
208
+ _rowContentCache.delete(cacheKey);
209
+ _rowContentCache.set(cacheKey, cached);
210
+ return cached;
211
+ }
212
+
213
+ return rememberRow(cacheKey, {
214
+ key,
215
+ spans: paddedSpans,
216
+ });
217
+ }
218
+
219
+ const _blankRowCache = new Map<string, TimelineRow>();
220
+
221
+ function createBlankRow(key: string, width: number): TimelineRow {
222
+ const cacheKey = `${key}:${width}`;
223
+ let row = _blankRowCache.get(cacheKey);
224
+ if (!row) {
225
+ row = createRow(key, [createSpan(" ".repeat(Math.max(0, width)))], width);
226
+ _blankRowCache.set(cacheKey, row);
227
+ }
228
+ return row;
229
+ }
230
+
231
+ interface StyledToken {
232
+ text: string;
233
+ isWhitespace: boolean;
234
+ isNewline: boolean;
235
+ tone?: TimelineTone;
236
+ bold?: boolean;
237
+ backgroundTone?: TimelineTone;
238
+ }
239
+
240
+ function flattenSpansToTokens(spans: TimelineRowSpan[]): StyledToken[] {
241
+ const tokens: StyledToken[] = [];
242
+ for (const span of spans) {
243
+ const parts = span.text.split(/([ \t\n]+)/);
244
+ for (const part of parts) {
245
+ if (part === "") continue;
246
+ const isNewline = part === "\n" || (part.includes("\n") && /^[\s]+$/.test(part));
247
+ const isWhitespace = /^[ \t\n]+$/.test(part);
248
+ tokens.push({
249
+ text: part,
250
+ isWhitespace,
251
+ isNewline,
252
+ tone: span.tone,
253
+ bold: span.bold,
254
+ backgroundTone: span.backgroundTone,
255
+ });
256
+ }
257
+ }
258
+ return tokens;
259
+ }
260
+
261
+ function wrapStyledSpans(spans: TimelineRowSpan[], width: number): TimelineRowSpan[][] {
262
+ const safeWidth = Math.max(1, width);
263
+ const rows: TimelineRowSpan[][] = [];
264
+ let currentRow: TimelineRowSpan[] = [];
265
+ let currentWidth = 0;
266
+
267
+ const pushRow = () => {
268
+ rows.push(currentRow.length > 0 ? currentRow : [createSpan("")]);
269
+ currentRow = [];
270
+ currentWidth = 0;
271
+ };
272
+
273
+ const spanFor = (token: StyledToken, text: string): TimelineRowSpan => ({
274
+ text,
275
+ ...(token.tone !== undefined ? { tone: token.tone } : {}),
276
+ ...(token.bold ? { bold: token.bold } : {}),
277
+ ...(token.backgroundTone !== undefined ? { backgroundTone: token.backgroundTone } : {}),
278
+ });
279
+
280
+ for (const token of flattenSpansToTokens(spans)) {
281
+ if (token.isNewline) {
282
+ pushRow();
283
+ continue;
284
+ }
285
+
286
+ const tokenWidth = getTextWidth(token.text);
287
+
288
+ if (token.isWhitespace) {
289
+ if (currentWidth === 0) continue; // skip leading whitespace on a new row
290
+ if (currentWidth + tokenWidth > safeWidth) {
291
+ pushRow();
292
+ continue; // drop whitespace that pushes us over the edge
293
+ }
294
+ appendSpan(currentRow, spanFor(token, token.text));
295
+ currentWidth += tokenWidth;
296
+ continue;
297
+ }
298
+
299
+ // Word token
300
+ if (currentWidth + tokenWidth > safeWidth && currentWidth > 0) {
301
+ pushRow();
302
+ }
303
+
304
+ // Overlong token: character-split across as many rows as needed
305
+ if (tokenWidth > safeWidth) {
306
+ let remaining = token.text;
307
+ let remainingWidth = tokenWidth;
308
+ while (remainingWidth > safeWidth - currentWidth) {
309
+ const available = safeWidth - currentWidth;
310
+ const split = splitTextAtColumn(remaining, available);
311
+ if (split.before) {
312
+ appendSpan(currentRow, spanFor(token, split.before));
313
+ }
314
+ pushRow();
315
+ remaining = split.current + split.after;
316
+ remainingWidth = getTextWidth(remaining);
317
+ }
318
+ if (remaining) {
319
+ appendSpan(currentRow, spanFor(token, remaining));
320
+ currentWidth += getTextWidth(remaining);
321
+ }
322
+ continue;
323
+ }
324
+
325
+ appendSpan(currentRow, spanFor(token, token.text));
326
+ currentWidth += tokenWidth;
327
+ }
328
+
329
+ if (currentRow.length === 0 && rows.length === 0) {
330
+ rows.push([createSpan("")]);
331
+ } else if (currentRow.length > 0) {
332
+ rows.push(currentRow);
333
+ }
334
+
335
+ return rows;
336
+ }
337
+
338
+ function buildPrefixedContentRows(
339
+ keyPrefix: string,
340
+ marker: TimelineRowSpan[],
341
+ continuationMarker: TimelineRowSpan[],
342
+ content: TimelineRowSpan[],
343
+ width: number,
344
+ ): TimelineRow[] {
345
+ const markerWidth = Math.max(0, getSpansWidth(marker));
346
+ const bodyWidth = Math.max(1, width - markerWidth);
347
+ const wrappedRows = wrapStyledSpans(content, bodyWidth);
348
+
349
+ return wrappedRows.map((row, index) => createRow(
350
+ `${keyPrefix}-${index}`,
351
+ [
352
+ ...(index === 0 ? marker : continuationMarker),
353
+ ...padSpansToWidth(row, bodyWidth),
354
+ ],
355
+ width,
356
+ ));
357
+ }
358
+
359
+ // ─── Border & card builders ───────────────────────────────────────────────────
360
+
361
+ function buildIndentedRows(
362
+ keyPrefix: string,
363
+ rows: TimelineRowSpan[][],
364
+ width: number,
365
+ indent: number,
366
+ ): TimelineRow[] {
367
+ const safeIndent = Math.max(0, indent);
368
+ const contentWidth = Math.max(1, width - safeIndent);
369
+ return rows.map((row, index) => createRow(
370
+ `${keyPrefix}-${index}`,
371
+ [
372
+ createSpan(" ".repeat(safeIndent)),
373
+ ...padSpansToWidth(row, contentWidth),
374
+ ],
375
+ width,
376
+ ));
377
+ }
378
+
379
+ function buildPlainRows(
380
+ keyPrefix: string,
381
+ lines: string[],
382
+ width: number,
383
+ tone?: TimelineTone,
384
+ ): TimelineRowSpan[][] {
385
+ return lines.flatMap((line, index) => wrapPlainText(line, Math.max(1, width)).map((row, rowIndex) => (
386
+ [createSpan(row || " ", tone)]
387
+ )));
388
+ }
389
+
390
+ function buildTopBorder(width: number, title: string, rightBadge?: string): TimelineRowSpan[] {
391
+ const safeWidth = Math.max(4, width);
392
+ const prefixWidth = 4;
393
+ const titleWidth = getTextWidth(title);
394
+ const badgeWidth = rightBadge ? getTextWidth(rightBadge) : 0;
395
+ const suffixWidth = rightBadge ? 4 : 3;
396
+ const fillSpacerWidth = rightBadge ? 2 : 1;
397
+ const fillCount = Math.max(1, safeWidth - prefixWidth - titleWidth - badgeWidth - suffixWidth - fillSpacerWidth);
398
+
399
+ const spans: TimelineRowSpan[] = [
400
+ createSpan("╭── ", "borderSubtle"),
401
+ createSpan(title, "muted", { bold: true }),
402
+ createSpan(rightBadge ? ` ${"─".repeat(fillCount)} ` : ` ${"─".repeat(fillCount)}`, "borderSubtle"),
403
+ ];
404
+
405
+ if (rightBadge) {
406
+ spans.push(createSpan(rightBadge, "dim"));
407
+ spans.push(createSpan(" ──╮", "borderSubtle"));
408
+ } else {
409
+ spans.push(createSpan("──╮", "borderSubtle"));
410
+ }
411
+
412
+ return spans;
413
+ }
414
+
415
+ function buildDashCardRows(params: {
416
+ keyPrefix: string;
417
+ width: number;
418
+ title: string;
419
+ rightBadge?: string;
420
+ borderTone?: TimelineTone;
421
+ titleTone?: TimelineTone;
422
+ badgeTone?: TimelineTone;
423
+ contentRows: TimelineRowSpan[][];
424
+ }): TimelineRow[] {
425
+ const width = Math.max(4, params.width);
426
+ const contentWidth = Math.max(1, width - 4);
427
+ const borderTone = params.borderTone ?? "borderSubtle";
428
+ const titleTone = params.titleTone ?? "muted";
429
+ const badgeTone = params.badgeTone ?? "dim";
430
+ const topBase = buildTopBorder(width, params.title, params.rightBadge);
431
+ const topRow = topBase.map((span) => {
432
+ if (span.tone === "muted") return { ...span, tone: titleTone };
433
+ if (span.tone === "dim") return { ...span, tone: badgeTone };
434
+ return { ...span, tone: borderTone };
435
+ });
436
+
437
+ const rows: TimelineRow[] = [createRow(`${params.keyPrefix}-top`, topRow, width)];
438
+
439
+ params.contentRows.forEach((row, index) => {
440
+ rows.push(createRow(
441
+ `${params.keyPrefix}-content-${index}`,
442
+ [
443
+ createSpan("│ ", borderTone),
444
+ ...padSpansToWidth(row, contentWidth),
445
+ createSpan(" │", borderTone),
446
+ ],
447
+ width,
448
+ ));
449
+ });
450
+
451
+ rows.push(createRow(
452
+ `${params.keyPrefix}-bottom`,
453
+ [createSpan(`╰${"─".repeat(Math.max(1, width - 2))}╯`, borderTone)],
454
+ width,
455
+ ));
456
+
457
+ return rows;
458
+ }
459
+
460
+ function buildPanelRows(params: {
461
+ keyPrefix: string;
462
+ width: number;
463
+ title: string;
464
+ rightTitle?: string;
465
+ contentRows: TimelineRowSpan[][];
466
+ }): TimelineRow[] {
467
+ const width = Math.max(10, params.width);
468
+ const leftLabel = ` ${params.title} `;
469
+ const rightLabel = params.rightTitle ? ` ${params.rightTitle} ` : "";
470
+ const dashCount = Math.max(0, width - 3 - getTextWidth(leftLabel) - getTextWidth(rightLabel));
471
+ const rows: TimelineRow[] = [
472
+ createRow(
473
+ `${params.keyPrefix}-top`,
474
+ [
475
+ createSpan("╭─", "borderActive"),
476
+ createSpan(leftLabel, "text"),
477
+ createSpan("─".repeat(dashCount), "borderActive"),
478
+ ...(params.rightTitle ? [createSpan(rightLabel, "dim")] : []),
479
+ createSpan("╮", "borderActive"),
480
+ ],
481
+ width,
482
+ ),
483
+ ];
484
+
485
+ const contentWidth = Math.max(1, width - 4);
486
+ params.contentRows.forEach((row, index) => {
487
+ rows.push(createRow(
488
+ `${params.keyPrefix}-content-${index}`,
489
+ [
490
+ createSpan("│ ", "borderActive"),
491
+ ...padSpansToWidth(row, contentWidth),
492
+ createSpan(" │", "borderActive"),
493
+ ],
494
+ width,
495
+ ));
496
+ });
497
+
498
+ rows.push(createRow(
499
+ `${params.keyPrefix}-bottom`,
500
+ [createSpan(`╰${"─".repeat(Math.max(1, width - 2))}╯`, "borderActive")],
501
+ width,
502
+ ));
503
+
504
+ return rows;
505
+ }
506
+
507
+ // ─── Turn content builders ────────────────────────────────────────────────────
508
+
509
+ function buildUserInputRows(item: Extract<RenderTimelineItem, { type: "turn" }>, width: number): TimelineRow[] {
510
+ const dim = item.renderState.opacity === "dim";
511
+ const contentWidth = Math.max(1, width - 4);
512
+ const lines = wrapPlainText(sanitizeTerminalOutput(item.item.user?.prompt ?? ""), Math.max(1, contentWidth - 2))
513
+ .map((line, index) => [
514
+ createSpan(index === 0 ? "❯ " : " ", dim ? "dim" : "text"),
515
+ createSpan(line || " ", dim ? "dim" : "text"),
516
+ ]);
517
+
518
+ return buildDashCardRows({
519
+ keyPrefix: `${item.key}-user`,
520
+ width,
521
+ title: "PROMPT",
522
+ borderTone: "borderSubtle",
523
+ contentRows: lines,
524
+ });
525
+ }
526
+
527
+ function formatDuration(ms: number): string {
528
+ if (ms < 1000) return `${ms}ms`;
529
+ return `${(ms / 1000).toFixed(1)}s`;
530
+ }
531
+
532
+ function buildTaskStatusRow(item: Extract<RenderTimelineItem, { type: "turn" }>, width: number): TimelineRow {
533
+ const run = item.item.run!;
534
+ // PERF: Do NOT call Date.now() here — this function runs inside buildTimelineSnapshot
535
+ // which is computed inside a useMemo in Timeline.tsx. Using Date.now() prevents the
536
+ // snapshot from ever fully stabilising, causing unnecessary downstream invalidation.
537
+ // We use a static frame so the data-layer row is deterministic and memo-stable.
538
+ const spinnerPlaceholder = "⠿";
539
+ const isActive = run.status === "running";
540
+
541
+ if (!isActive) {
542
+ // Completed state — clean summary line
543
+ const icon = run.status === "failed" ? "✕" : "✔";
544
+ const iconTone: TimelineTone = run.status === "failed" ? "error" : "success";
545
+ const label = run.status === "failed" ? "Failed" : run.status === "canceled" ? "Canceled" : "Complete";
546
+ const durationText = run.durationMs != null ? ` • ${formatDuration(run.durationMs)}` : "";
547
+ return createRow(
548
+ `${item.key}-status`,
549
+ [
550
+ createSpan(" "),
551
+ createSpan(`${icon} `, iconTone),
552
+ createSpan(`${label}${durationText}`, "dim"),
553
+ ],
554
+ width,
555
+ );
556
+ }
557
+
558
+ // Active state — static concise status. The bottom status slot owns the
559
+ // live busy animation so transcript rows do not repaint on animation ticks.
560
+ const statusText = item.renderState.runPhase === "streaming"
561
+ ? "Codexa is streaming"
562
+ : item.renderState.runPhase === "final"
563
+ ? "Codexa response complete"
564
+ : "Codexa is thinking";
565
+
566
+ return createRow(
567
+ `${item.key}-status`,
568
+ [
569
+ createSpan(" "),
570
+ createSpan(`${spinnerPlaceholder} `, "info"),
571
+ createSpan(statusText, "muted"),
572
+ ],
573
+ width,
574
+ );
575
+ }
576
+
577
+ function getShellFailureExcerpt(event: ShellEvent): string[] {
578
+ const source = event.stderrLines.length > 0 ? event.stderrLines : event.lines;
579
+ const summary = sanitizeTerminalOutput(event.summary ?? "").trim().toLowerCase();
580
+ return sanitizeTerminalLines(source)
581
+ .map((line) => line.trim())
582
+ .filter(Boolean)
583
+ .filter((line, index) => !(index === 0 && summary && line.toLowerCase() === summary))
584
+ .slice(0, MAX_SHELL_FAILURE_EXCERPT_LINES);
585
+ }
586
+
587
+ function getProgressBlockMarker(isLive: boolean): { text: string; tone: TimelineTone } {
588
+ if (isLive) {
589
+ return { text: "▸ ", tone: "accent" };
590
+ }
591
+ return { text: "• ", tone: "info" };
592
+ }
593
+
594
+ function getCurrentProgressText(block: VisibleProgressBlock | null, latestTool: RunEvent["toolActivities"][number] | null): string | null {
595
+ if (latestTool?.status === "running") {
596
+ return latestTool.command;
597
+ }
598
+
599
+ if (!block) {
600
+ return null;
601
+ }
602
+
603
+ return block.headline.replace(/^Current:\s*/i, "");
604
+ }
605
+
606
+ /**
607
+ * Verbose mode renders the full reasoning card.
608
+ * Default mode renders a compact live-activity card only when there are
609
+ * meaningful progress, tool, or file signals to show.
610
+ */
611
+ function buildThinkingRows(run: RunEvent, width: number, verbose: boolean): TimelineRow[] {
612
+ const latestTool = run.toolActivities[run.toolActivities.length - 1] ?? null;
613
+ const progressEntries = run.progressEntries ?? [];
614
+ const recentActivity = run.activity.slice(-2);
615
+ const contentWidth = Math.max(1, width - 4);
616
+ const contentRows: TimelineRowSpan[][] = [];
617
+ const totalProgressBlocks = getProgressUpdateCount(progressEntries);
618
+ const maxVisibleEntries = verbose ? totalProgressBlocks : MAX_VISIBLE_PROGRESS_ENTRIES;
619
+ const {
620
+ blocks: visibleBlocks,
621
+ hiddenCount,
622
+ totalCount,
623
+ latestBlock,
624
+ latestActiveBlock,
625
+ } = selectVisibleProgressBlocks(progressEntries, maxVisibleEntries);
626
+ const updateCount = totalCount || totalProgressBlocks;
627
+ const currentProgressText = getCurrentProgressText(latestActiveBlock ?? latestBlock, latestTool);
628
+
629
+ if (currentProgressText && run.status === "running") {
630
+ contentRows.push([
631
+ createSpan("Current: ", "info", { bold: true }),
632
+ createSpan(clampVisualText(currentProgressText, Math.max(1, contentWidth - 9)), "text"),
633
+ ]);
634
+ }
635
+
636
+ if (hiddenCount > 0) {
637
+ if (contentRows.length > 0) contentRows.push([createSpan(" ", "dim")]);
638
+ contentRows.push([createSpan(`... ${hiddenCount} earlier update${hiddenCount === 1 ? "" : "s"}`, "dim")]);
639
+ }
640
+
641
+ visibleBlocks.forEach((block, blockIndex) => {
642
+ const isLive = run.status === "running" && block.isActive;
643
+ if (contentRows.length > 0 && (blockIndex > 0 || hiddenCount > 0)) {
644
+ contentRows.push([createSpan(" ", "dim")]);
645
+ }
646
+
647
+ const marker = getProgressBlockMarker(isLive);
648
+ const label = isLive ? "Live" : block.label;
649
+ contentRows.push([
650
+ createSpan(marker.text, marker.tone),
651
+ createSpan(label, isLive ? "accent" : "info", { bold: isLive }),
652
+ ]);
653
+
654
+ const bodyLines = formatProgressBlockBodyLines(block.text, Math.max(1, contentWidth - 4));
655
+ const lineCap = verbose ? bodyLines.length : COMPACT_PROCESSING_BODY_LINE_CAP;
656
+ const visibleBodyLines = bodyLines.slice(0, lineCap);
657
+ const overflowCount = bodyLines.length - visibleBodyLines.length;
658
+
659
+ visibleBodyLines.forEach((line) => {
660
+ contentRows.push([
661
+ createSpan(isLive ? " │ " : " ", isLive ? "accent" : undefined),
662
+ createSpan(line || " ", "dim"),
663
+ ]);
664
+ });
665
+
666
+ if (overflowCount > 0) {
667
+ contentRows.push([
668
+ createSpan(" "),
669
+ createSpan(`… (${overflowCount} more line${overflowCount === 1 ? "" : "s"})`, "dim"),
670
+ ]);
671
+ }
672
+ });
673
+
674
+ if (run.status === "running" && latestTool) {
675
+ const toolPrefix = latestTool.status === "failed" ? "✕ " : latestTool.status === "completed" ? "✓ " : "• ";
676
+ const toolTone = latestTool.status === "failed" ? "error" : latestTool.status === "completed" ? "success" : "info";
677
+ const toolText = latestTool.status === "running"
678
+ ? latestTool.command
679
+ : latestTool.summary ?? latestTool.command;
680
+ const clampedTool = clampVisualText(toolText, Math.max(1, contentWidth - 2));
681
+ if (clampedTool.trim()) {
682
+ if (contentRows.length > 0) contentRows.push([createSpan(" ", "dim")]);
683
+ contentRows.push([
684
+ createSpan(toolPrefix, toolTone),
685
+ createSpan(clampedTool, toolTone),
686
+ ]);
687
+ }
688
+ }
689
+
690
+ if (run.status === "running") {
691
+ recentActivity.forEach((file, index) => {
692
+ const prefix = file.operation === "created" ? "+ " : file.operation === "deleted" ? "- " : "~ ";
693
+ const tone = file.operation === "created" ? "success" : file.operation === "deleted" ? "error" : "info";
694
+ const text = clampVisualText(file.path, Math.max(1, contentWidth - 2));
695
+ if (!text.trim()) return;
696
+ if (contentRows.length > 0 && index === 0) contentRows.push([createSpan(" ", "dim")]);
697
+ contentRows.push([
698
+ createSpan(prefix, tone),
699
+ createSpan(text, tone),
700
+ ]);
701
+ });
702
+ }
703
+
704
+ if (contentRows.length === 0) {
705
+ return [];
706
+ }
707
+
708
+ return buildDashCardRows({
709
+ keyPrefix: `${run.turnId}-thinking`,
710
+ width,
711
+ title: "Processing",
712
+ rightBadge: run.status === "running"
713
+ ? "active"
714
+ : `${updateCount} update${updateCount === 1 ? "" : "s"}`,
715
+ borderTone: run.status === "running" ? "borderActive" : "borderSubtle",
716
+ contentRows,
717
+ });
718
+ }
719
+
720
+ /**
721
+ * Compact impact summary for completed runs (default mode).
722
+ * Shows file changes and a summary footer.
723
+ */
724
+ function buildImpactSummaryRows(item: Extract<RenderTimelineItem, { type: "turn" }>, width: number): TimelineRow[] {
725
+ const run = item.item.run!;
726
+ const summary = run.activitySummary;
727
+ const hasFiles = run.touchedFileCount > 0;
728
+ const streamItemTools = new Set((run.streamItems ?? []).filter((i) => i.kind === "action").map((i) => i.refId));
729
+ const unstreamedTools = run.toolActivities.filter((t) => !streamItemTools.has(t.id));
730
+ const hasUnstreamedTools = unstreamedTools.length > 0;
731
+ if (!hasFiles && !hasUnstreamedTools) return [];
732
+
733
+ const rows: TimelineRow[] = [];
734
+ const recentFiles = summary?.recent ?? run.activity.slice(-6);
735
+ const hasDeletes = (summary?.deleted ?? 0) > 0;
736
+
737
+ const opLabel = (op: string) => {
738
+ switch (op) {
739
+ case "created": return "CREATED ";
740
+ case "modified": return "MODIFIED";
741
+ case "deleted": return "DELETED ";
742
+ default: return op.toUpperCase().padEnd(8);
743
+ }
744
+ };
745
+ const opTone = (op: string): TimelineTone => {
746
+ switch (op) {
747
+ case "created": return "success";
748
+ case "deleted": return "error";
749
+ default: return "info";
750
+ }
751
+ };
752
+
753
+ // Warning banner for destructive changes
754
+ if (hasDeletes) {
755
+ rows.push(createRow(
756
+ `${item.key}-impact-warn`,
757
+ [createSpan(" "), createSpan("⚠ Destructive changes detected:", "warning")],
758
+ width,
759
+ ));
760
+ }
761
+
762
+ // "Changes:" label
763
+ if (hasFiles) {
764
+ rows.push(createRow(
765
+ `${item.key}-impact-label`,
766
+ [createSpan(" "), createSpan("Changes:", "dim")],
767
+ width,
768
+ ));
769
+
770
+ // File list
771
+ recentFiles.forEach((file, index) => {
772
+ const diffInfo = file.addedLines != null || file.removedLines != null
773
+ ? ` (+${file.addedLines ?? 0} -${file.removedLines ?? 0})`
774
+ : "";
775
+ rows.push(createRow(
776
+ `${item.key}-impact-file-${index}`,
777
+ [
778
+ createSpan(" "),
779
+ createSpan(opLabel(file.operation), opTone(file.operation)),
780
+ createSpan(` ${file.path}`, "text"),
781
+ createSpan(diffInfo, "dim"),
782
+ ],
783
+ width,
784
+ ));
785
+ });
786
+ }
787
+
788
+ // Summary footer
789
+ const parts: string[] = [];
790
+ if (run.touchedFileCount > 0) parts.push(`${run.touchedFileCount} file${run.touchedFileCount === 1 ? "" : "s"}`);
791
+ if (hasUnstreamedTools) parts.push(`${unstreamedTools.length} action${unstreamedTools.length === 1 ? "" : "s"}`);
792
+ if (run.durationMs != null) parts.push(formatDuration(run.durationMs));
793
+
794
+ rows.push(createRow(
795
+ `${item.key}-impact-summary`,
796
+ [
797
+ createSpan(" "),
798
+ createSpan("✔ ", "success"),
799
+ createSpan(parts.join(" • "), "dim"),
800
+ ],
801
+ width,
802
+ ));
803
+
804
+ return rows;
805
+ }
806
+
807
+ // ─── Markdown rendering ───────────────────────────────────────────────────────
808
+
809
+ function normalizeMarkdownParts(parts: unknown): MarkdownInlinePart[] {
810
+ if (!Array.isArray(parts)) return [];
811
+ return parts
812
+ .filter((part): part is MarkdownInlinePart => (
813
+ typeof part === "object"
814
+ && part !== null
815
+ && ("kind" in part)
816
+ && ("text" in part)
817
+ && typeof (part as { kind: unknown }).kind === "string"
818
+ && typeof (part as { text: unknown }).text === "string"
819
+ ))
820
+ .map((part) => ({
821
+ kind: part.kind,
822
+ text: part.text,
823
+ }));
824
+ }
825
+
826
+ function inlinePartsToSpans(parts: MarkdownInlinePart[], tone: TimelineTone): TimelineRowSpan[] {
827
+ const spans: TimelineRowSpan[] = [];
828
+ parts.forEach((part) => {
829
+ if (part.kind === "code") {
830
+ appendSpan(spans, createSpan(part.text, "info"));
831
+ return;
832
+ }
833
+ if (part.kind === "bold") {
834
+ appendSpan(spans, createSpan(part.text, tone, { bold: true }));
835
+ return;
836
+ }
837
+ appendSpan(spans, createSpan(part.text, tone));
838
+ });
839
+ return spans;
840
+ }
841
+
842
+ function buildWrappedMarkdownLine(
843
+ keyPrefix: string,
844
+ parts: MarkdownInlinePart[],
845
+ width: number,
846
+ tone: TimelineTone,
847
+ ): TimelineRowSpan[][] {
848
+ return wrapStyledSpans(inlinePartsToSpans(parts, tone), width)
849
+ .map((row, index) => padSpansToWidth(row, width));
850
+ }
851
+
852
+ function getDiffTone(kind: DiffRenderLineType): TimelineTone {
853
+ switch (kind) {
854
+ case "add":
855
+ return "success";
856
+ case "remove":
857
+ return "error";
858
+ case "hunk":
859
+ return "accent";
860
+ case "file":
861
+ case "meta":
862
+ return "info";
863
+ case "context":
864
+ default:
865
+ return "muted";
866
+ }
867
+ }
868
+
869
+ function buildCodePanelRows(keyPrefix: string, segment: Extract<Segment, { type: "code" }>, width: number): TimelineRowSpan[][] {
870
+ let title = segment.lang || "code";
871
+ let codeLines = segment.lines;
872
+ const firstLine = codeLines[0]?.trim() ?? "";
873
+ if (/^[a-zA-Z0-9_.\-\/]+\.[a-zA-Z0-9]+$/.test(firstLine)) {
874
+ title = firstLine;
875
+ codeLines = codeLines.slice(1);
876
+ }
877
+
878
+ const rightTitle = segment.lang ? `${segment.lang.toUpperCase()} ⎘ Copy Code` : "⎘ Copy Code";
879
+ const panelWidth = Math.max(10, width - 2);
880
+ const panelContentWidth = Math.max(1, panelWidth - 4);
881
+
882
+ const diffLines = maybeRenderDiff(codeLines.join("\n"), {
883
+ force: segment.lang.toLowerCase() === "diff",
884
+ });
885
+ const contentRows: TimelineRowSpan[][] = [];
886
+
887
+ if (diffLines) {
888
+ diffLines.forEach((line) => {
889
+ wrapPlainText(line.text, panelContentWidth).forEach((wrapped) => {
890
+ contentRows.push([createSpan(wrapped || " ", getDiffTone(line.type))]);
891
+ });
892
+ });
893
+ } else {
894
+ codeLines.forEach((line, index) => {
895
+ const wrappedRows = wrapPlainText(line, Math.max(1, panelContentWidth - 4));
896
+ wrappedRows.forEach((wrapped, rowIndex) => {
897
+ contentRows.push([
898
+ createSpan(rowIndex === 0 ? `${String(index + 1).padStart(3, " ")} ` : " ", "dim"),
899
+ createSpan(wrapped || " ", "muted"),
900
+ ]);
901
+ });
902
+ });
903
+ }
904
+
905
+ const panelRows = buildPanelRows({
906
+ keyPrefix,
907
+ width: panelWidth,
908
+ title,
909
+ rightTitle,
910
+ contentRows,
911
+ });
912
+
913
+ return panelRows.map((row) => [
914
+ createSpan(" "),
915
+ ...padSpansToWidth(row.spans, panelWidth),
916
+ ]);
917
+ }
918
+
919
+ function buildMarkdownRows(segments: Segment[], width: number): TimelineRowSpan[][] {
920
+ const rows: TimelineRowSpan[][] = [];
921
+
922
+ segments.forEach((segment, segmentIndex) => {
923
+ const marginTop = segmentIndex > 0 ? 1 : 0;
924
+ if (marginTop > 0) {
925
+ rows.push([createSpan("")]);
926
+ }
927
+
928
+ if (segment.type === "code") {
929
+ rows.push(...buildCodePanelRows(`code-${segmentIndex}`, segment, width));
930
+ return;
931
+ }
932
+
933
+ if (segment.type === "header") {
934
+ const parts = normalizeMarkdownParts(segment.parts);
935
+ const prefix = segment.level <= 2 ? "✧ " : "• ";
936
+ const prefixTone = segment.level === 1 ? "accent" : segment.level === 2 ? "text" : "muted";
937
+ if (segment.level <= 2) {
938
+ rows.push([createSpan("───", "borderSubtle")]);
939
+ }
940
+ rows.push(...buildPrefixedContentRows(
941
+ `header-${segmentIndex}`,
942
+ [createSpan(prefix, prefixTone)],
943
+ [createSpan(" ", prefixTone)],
944
+ inlinePartsToSpans(parts, prefixTone),
945
+ width,
946
+ ).map((row) => row.spans));
947
+ return;
948
+ }
949
+
950
+ if (segment.type === "list") {
951
+ segment.items.forEach((item, itemIndex) => {
952
+ const prefix = segment.ordered ? `${item.num}. ` : "• ";
953
+ rows.push(...buildPrefixedContentRows(
954
+ `list-${segmentIndex}-${itemIndex}`,
955
+ [createSpan(prefix, "accent")],
956
+ [createSpan(" ".repeat(getTextWidth(prefix)), "accent")],
957
+ inlinePartsToSpans(normalizeMarkdownParts(item.parts), "text"),
958
+ width,
959
+ ).map((row) => row.spans));
960
+ });
961
+ return;
962
+ }
963
+
964
+ // Paragraph segment — check if it looks like a unified diff so we can
965
+ // apply colour-coded tones instead of the flat 'text' tone.
966
+ const rawParaLines = segment.lines.map((parts) =>
967
+ normalizeMarkdownParts(parts).map((p) => p.text).join(""),
968
+ );
969
+ const diffLines = maybeRenderDiff(rawParaLines.join("\n"));
970
+ const diffLineByIndex = new Map<number, NonNullable<ReturnType<typeof maybeRenderDiff>>[number]>();
971
+ diffLines?.forEach((line, index) => {
972
+ diffLineByIndex.set(index, line);
973
+ });
974
+
975
+ segment.lines.forEach((parts, lineIndex) => {
976
+ const normalizedParts = normalizeMarkdownParts(parts);
977
+ const isBlank = normalizedParts.length === 1
978
+ && normalizedParts[0]?.kind === "text"
979
+ && !normalizedParts[0].text.trim();
980
+ if (isBlank) {
981
+ return;
982
+ }
983
+
984
+ const diffLine = diffLineByIndex.get(lineIndex);
985
+ if (diffLine) {
986
+ wrapStyledSpans([createSpan(diffLine.text, getDiffTone(diffLine.type))], width)
987
+ .forEach((row) => rows.push(padSpansToWidth(row, width)));
988
+ return;
989
+ }
990
+
991
+ rows.push(...buildWrappedMarkdownLine(`para-${segmentIndex}-${lineIndex}`, normalizedParts, width, "text"));
992
+ });
993
+ });
994
+
995
+ return rows.length > 0 ? rows : [];
996
+ }
997
+
998
+ // ─── Row cache ────────────────────────────────────────────────────────────────
999
+ // During streaming, we cache previously computed markdown rows and only re-run
1000
+ // the pipeline on new content from the last safe paragraph boundary onward.
1001
+ // This reduces per-frame work from O(total_content) to O(new_delta + tail_paragraph).
1002
+ interface StreamingRowCache {
1003
+ turnKey: string;
1004
+ width: number;
1005
+ /** Content length up to the last safe boundary that produced cachedRows. */
1006
+ safeBoundaryOffset: number;
1007
+ /** Rows computed for content up to safeBoundaryOffset. */
1008
+ cachedRows: TimelineRowSpan[][];
1009
+ /** Total content length when this cache was last updated. */
1010
+ contentLength: number;
1011
+ }
1012
+
1013
+ let _streamingRowCache: StreamingRowCache | null = null;
1014
+
1015
+ // Per-entry row cache for completed (non-streaming) timeline entries.
1016
+ // Key: `${item.key}:${width}:${verboseMode}` — automatically invalidated when
1017
+ // width or verboseMode changes because those are baked into the key. Entries
1018
+ // for completed turns are immutable so cached rows are always valid for the
1019
+ // same (key, width, verboseMode) triple.
1020
+ const _staticRowCache = new Map<string, TimelineRow[]>();
1021
+ const STREAMING_BLOCK_ROW_CACHE_LIMIT = 200;
1022
+ let _streamingBlockRowCache = new Map<string, TimelineRow[]>();
1023
+ const _completedActionRowCache = new Map<string, TimelineRow[]>();
1024
+ const _completedActionTokenById = new Map<string, string>();
1025
+ const FROZEN_ROW_GROUP_CACHE_LIMIT = 1200;
1026
+ const _frozenRowGroupCache = new Map<string, TimelineRow[]>();
1027
+ let _wrappedRowCache = new WeakMap<TimelineRow, Map<string, TimelineRow>>();
1028
+ const _wrappedBlankRowCache = new Map<string, TimelineRow>();
1029
+ interface ActionDisplayDescriptor {
1030
+ id: string;
1031
+ status: RunToolActivity["status"];
1032
+ label: string | null;
1033
+ command: string;
1034
+ duration: string;
1035
+ summary: string;
1036
+ icon: string;
1037
+ iconTone: TimelineTone;
1038
+ showLiveCursor: boolean;
1039
+ borderTone: TimelineTone;
1040
+ width: number;
1041
+ verbose: boolean;
1042
+ }
1043
+
1044
+ const _actionDisplayCache = new Map<string, ActionDisplayDescriptor>();
1045
+
1046
+ function hashString(value: string): string {
1047
+ let hash = 5381;
1048
+ for (let index = 0; index < value.length; index += 1) {
1049
+ hash = ((hash << 5) + hash) ^ value.charCodeAt(index);
1050
+ }
1051
+ return (hash >>> 0).toString(36);
1052
+ }
1053
+
1054
+ function textCacheToken(value: string | null | undefined): string {
1055
+ const text = value ?? "";
1056
+ return `${text.length}:${hashString(text)}`;
1057
+ }
1058
+
1059
+ function rowCacheKey(parts: unknown[]): string {
1060
+ return JSON.stringify(parts);
1061
+ }
1062
+
1063
+ function getCachedStreamingBlockRows(cacheKey: string, build: () => TimelineRow[]): TimelineRow[] {
1064
+ const cached = _streamingBlockRowCache.get(cacheKey);
1065
+ if (cached) {
1066
+ _streamingBlockRowCache.delete(cacheKey);
1067
+ _streamingBlockRowCache.set(cacheKey, cached);
1068
+ return cached;
1069
+ }
1070
+
1071
+ const rows = build();
1072
+ _streamingBlockRowCache.set(cacheKey, rows);
1073
+ while (_streamingBlockRowCache.size > STREAMING_BLOCK_ROW_CACHE_LIMIT) {
1074
+ const oldestKey = _streamingBlockRowCache.keys().next().value;
1075
+ if (oldestKey === undefined) break;
1076
+ _streamingBlockRowCache.delete(oldestKey);
1077
+ }
1078
+ return rows;
1079
+ }
1080
+
1081
+ function getCachedFrozenRows(cacheKey: string, build: () => TimelineRow[]): TimelineRow[] {
1082
+ const cached = _frozenRowGroupCache.get(cacheKey);
1083
+ if (cached) {
1084
+ _frozenRowGroupCache.delete(cacheKey);
1085
+ _frozenRowGroupCache.set(cacheKey, cached);
1086
+ return cached;
1087
+ }
1088
+
1089
+ const rows = build();
1090
+ _frozenRowGroupCache.set(cacheKey, rows);
1091
+ while (_frozenRowGroupCache.size > FROZEN_ROW_GROUP_CACHE_LIMIT) {
1092
+ const oldestKey = _frozenRowGroupCache.keys().next().value;
1093
+ if (oldestKey === undefined) break;
1094
+ _frozenRowGroupCache.delete(oldestKey);
1095
+ }
1096
+ return rows;
1097
+ }
1098
+
1099
+ export function __clearTimelineMeasureCachesForTests(): void {
1100
+ _streamingRowCache = null;
1101
+ _rowContentCache.clear();
1102
+ _staticRowCache.clear();
1103
+ _blankRowCache.clear();
1104
+ _streamingBlockRowCache.clear();
1105
+ _completedActionRowCache.clear();
1106
+ _completedActionTokenById.clear();
1107
+ _frozenRowGroupCache.clear();
1108
+ _wrappedRowCache = new WeakMap<TimelineRow, Map<string, TimelineRow>>();
1109
+ _wrappedBlankRowCache.clear();
1110
+ _actionDisplayCache.clear();
1111
+ }
1112
+
1113
+ export function __getStreamingBlockRowCacheSizeForTests(): number {
1114
+ return _streamingBlockRowCache.size;
1115
+ }
1116
+
1117
+ export function __wrapStyledSpansForTests(spans: TimelineRowSpan[], width: number): TimelineRowSpan[][] {
1118
+ return wrapStyledSpans(spans, width);
1119
+ }
1120
+
1121
+ /** Find the last safe paragraph boundary (double newline or closed code fence)
1122
+ * that we can split content at for incremental rendering. */
1123
+ function findSafeBoundary(content: string, searchFrom: number): number {
1124
+ // Look for the last double-newline before the end of content
1125
+ let boundary = content.lastIndexOf("\n\n", content.length - 1);
1126
+ // Only accept boundaries past the previous safe offset
1127
+ if (boundary > searchFrom) return boundary + 2; // include the \n\n
1128
+
1129
+ // Fallback: look for single newline that's past searchFrom
1130
+ boundary = content.lastIndexOf("\n", content.length - 1);
1131
+ if (boundary > searchFrom) return boundary + 1;
1132
+
1133
+ // No safe boundary found — must re-process from searchFrom
1134
+ return searchFrom;
1135
+ }
1136
+
1137
+ // ─── Agent & action builders ──────────────────────────────────────────────────
1138
+
1139
+ function buildAgentRows(item: Extract<RenderTimelineItem, { type: "turn" }>, width: number, verbose = false): TimelineRow[] {
1140
+ const run = item.item.run!;
1141
+ const assistant = item.item.assistant;
1142
+ const streaming = item.renderState.runPhase === "streaming";
1143
+ const dim = item.renderState.opacity !== "active";
1144
+ const contentWidth = Math.max(1, width - 4);
1145
+ const rawContent = splitSentenceWall(getAssistantContent(assistant));
1146
+
1147
+ let contentRows: TimelineRowSpan[][];
1148
+
1149
+ if (streaming && rawContent.length > 0) {
1150
+ // During streaming, content was already sanitized in onAssistantDelta (app.tsx).
1151
+ // Skip redundant sanitizeStreamChunk call — pass directly to normalize.
1152
+ const turnKey = item.key;
1153
+ const cache = _streamingRowCache;
1154
+
1155
+ if (
1156
+ cache
1157
+ && cache.turnKey === turnKey
1158
+ && cache.width === contentWidth
1159
+ && rawContent.length >= cache.contentLength
1160
+ ) {
1161
+ // Content is a strict extension of what we cached — incremental update.
1162
+ const newBoundary = findSafeBoundary(rawContent, cache.safeBoundaryOffset);
1163
+
1164
+ // Re-process only content from the last safe boundary onward
1165
+ const tailContent = rawContent.slice(cache.safeBoundaryOffset);
1166
+ const tailNormalized = normalizeOutput(tailContent);
1167
+ const tailSegments = formatForBox(classifyOutput(tailNormalized), contentWidth);
1168
+ const tailRows = buildMarkdownRows(tailSegments, contentWidth);
1169
+ contentRows = [...cache.cachedRows, ...tailRows];
1170
+
1171
+ if (newBoundary > cache.safeBoundaryOffset) {
1172
+ // New safe boundary found — compute cached rows up to boundary
1173
+ const safePart = rawContent.slice(cache.safeBoundaryOffset, newBoundary);
1174
+ const safeNormalized = normalizeOutput(safePart);
1175
+ const safeSegments = formatForBox(classifyOutput(safeNormalized), contentWidth);
1176
+ const safeRows = buildMarkdownRows(safeSegments, contentWidth);
1177
+
1178
+ _streamingRowCache = {
1179
+ turnKey,
1180
+ width: contentWidth,
1181
+ safeBoundaryOffset: newBoundary,
1182
+ cachedRows: [...cache.cachedRows, ...safeRows],
1183
+ contentLength: rawContent.length,
1184
+ };
1185
+ } else {
1186
+ // No new safe boundary — keep cache as-is, just update content length
1187
+ _streamingRowCache = {
1188
+ ...cache,
1189
+ contentLength: rawContent.length,
1190
+ };
1191
+ }
1192
+ } else {
1193
+ // Cache miss — full rebuild and seed the cache
1194
+ const normalized = normalizeOutput(rawContent);
1195
+ const segments = formatForBox(classifyOutput(normalized), contentWidth);
1196
+ contentRows = buildMarkdownRows(segments, contentWidth);
1197
+
1198
+ const boundary = findSafeBoundary(rawContent, 0);
1199
+ if (boundary > 0 && boundary < rawContent.length) {
1200
+ const safeNormalized = normalizeOutput(rawContent.slice(0, boundary));
1201
+ const safeSegments = formatForBox(classifyOutput(safeNormalized), contentWidth);
1202
+ const safeRows = buildMarkdownRows(safeSegments, contentWidth);
1203
+
1204
+ _streamingRowCache = {
1205
+ turnKey,
1206
+ width: contentWidth,
1207
+ safeBoundaryOffset: boundary,
1208
+ cachedRows: safeRows,
1209
+ contentLength: rawContent.length,
1210
+ };
1211
+ } else {
1212
+ _streamingRowCache = {
1213
+ turnKey,
1214
+ width: contentWidth,
1215
+ safeBoundaryOffset: 0,
1216
+ cachedRows: [],
1217
+ contentLength: rawContent.length,
1218
+ };
1219
+ }
1220
+ }
1221
+ } else {
1222
+ // Not streaming or empty — full pipeline, invalidate cache
1223
+ if (!streaming) _streamingRowCache = null;
1224
+ const sanitized = sanitizeOutput(rawContent);
1225
+ const normalized = normalizeOutput(sanitized);
1226
+ const segments = formatForBox(classifyOutput(normalized), contentWidth);
1227
+ contentRows = buildMarkdownRows(segments, contentWidth);
1228
+ }
1229
+
1230
+ if (!streaming && run.status === "failed") {
1231
+ const failureMessage = sanitizeTerminalOutput(run.errorMessage ?? run.summary);
1232
+ const failureRows: TimelineRowSpan[][] = [];
1233
+ wrapPlainText(failureMessage, Math.max(1, contentWidth - 2)).forEach((row, index) => {
1234
+ failureRows.push([
1235
+ createSpan(index === 0 ? "✕ " : " ", "error"),
1236
+ createSpan(row || " ", "error"),
1237
+ ]);
1238
+ });
1239
+ contentRows = [...failureRows, ...contentRows];
1240
+ }
1241
+
1242
+ if (streaming && !verbose && contentRows.length > COMPACT_STREAMING_TAIL_CAP) {
1243
+ const hiddenRowCount = contentRows.length - COMPACT_STREAMING_TAIL_CAP;
1244
+ contentRows = [
1245
+ [createSpan(`… (${hiddenRowCount} line${hiddenRowCount === 1 ? "" : "s"} above)`, "dim")],
1246
+ ...contentRows.slice(-COMPACT_STREAMING_TAIL_CAP),
1247
+ ];
1248
+ }
1249
+
1250
+ if (streaming) {
1251
+ contentRows.push([
1252
+ createSpan(" "),
1253
+ createSpan("▌", "accent"),
1254
+ ]);
1255
+ }
1256
+
1257
+ if (!streaming && run.status !== "running") {
1258
+ if (run.status === "canceled") {
1259
+ wrapPlainText(sanitizeTerminalOutput(run.summary), contentWidth).forEach((wrapped) => {
1260
+ contentRows.push([createSpan(wrapped || " ", "warning")]);
1261
+ });
1262
+ } else if (run.status === "completed" && rawContent.trim().length === 0) {
1263
+ contentRows.push([createSpan("(no output)", "dim")]);
1264
+ }
1265
+
1266
+ if (run.truncatedOutput) {
1267
+ contentRows.push([createSpan(RUN_OUTPUT_TRUNCATION_NOTICE, "dim")]);
1268
+ }
1269
+ }
1270
+
1271
+ const heading = run.runtime.model ? run.runtime.model.toUpperCase().replace(/-/g, " ") : "Codex";
1272
+ const runStatus = streaming
1273
+ ? "streaming"
1274
+ : run.status === "completed"
1275
+ ? "complete"
1276
+ : run.status ?? "running";
1277
+ const rightBadge = run.durationMs != null && !streaming
1278
+ ? `${runStatus} • ${formatDuration(run.durationMs)}`
1279
+ : runStatus;
1280
+
1281
+ const borderTone = dim ? "borderSubtle" : streaming ? "borderActive" : "borderSubtle";
1282
+ const actionBorderTone = item.renderState.opacity === "dim" ? "borderSubtle" : "borderActive";
1283
+
1284
+ const rows: TimelineRow[] = [];
1285
+
1286
+ // 1. Add top margin for separation from the task status line above.
1287
+ rows.push(createBlankRow(`${item.key}-agent-top-gap`, width));
1288
+
1289
+ // 2. Render the Codex output inside a DashCard — visually consistent with
1290
+ // every other block in the timeline: USER INPUT, Processing, File Scan,
1291
+ // and Activity all use the same ╭──...──╮ frame. The title is the model
1292
+ // name (e.g. "GPT 4O") or the generic "Codex" fallback.
1293
+ rows.push(...buildDashCardRows({
1294
+ keyPrefix: `${item.key}-agent`,
1295
+ width,
1296
+ title: heading,
1297
+ rightBadge,
1298
+ borderTone,
1299
+ contentRows,
1300
+ }));
1301
+
1302
+ return rows;
1303
+ }
1304
+
1305
+ function buildFileScanRows(item: Extract<RenderTimelineItem, { type: "turn" }>, width: number): TimelineRow[] {
1306
+ const run = item.item.run!;
1307
+ const { visible, hiddenCount } = selectVisibleRunActivity(run);
1308
+ const contentRows: TimelineRowSpan[][] = [];
1309
+
1310
+ if (hiddenCount > 0) {
1311
+ contentRows.push([createSpan(`... ${hiddenCount} more`, "dim")]);
1312
+ }
1313
+
1314
+ visible.forEach((file) => {
1315
+ contentRows.push([
1316
+ createSpan("● ", "success"),
1317
+ createSpan(file.path, "text"),
1318
+ ]);
1319
+ });
1320
+
1321
+ return buildDashCardRows({
1322
+ keyPrefix: `${item.key}-files`,
1323
+ width,
1324
+ title: "Scanning workspace ...",
1325
+ rightBadge: `${run.touchedFileCount} file${run.touchedFileCount === 1 ? "" : "s"}`,
1326
+ contentRows,
1327
+ });
1328
+ }
1329
+
1330
+ function buildActivityRows(item: Extract<RenderTimelineItem, { type: "turn" }>, width: number): TimelineRow[] {
1331
+ const run = item.item.run!;
1332
+ const contentWidth = Math.max(1, width - 4);
1333
+ const contentRows: TimelineRowSpan[][] = [];
1334
+
1335
+ run.toolActivities.forEach((tool, index) => {
1336
+ const icon = tool.status === "failed" ? "✕" : "✓";
1337
+ const iconTone = tool.status === "failed" ? "error" : "success";
1338
+ const duration = tool.completedAt && tool.startedAt
1339
+ ? ` • ${formatDuration(tool.completedAt - tool.startedAt)}`
1340
+ : "";
1341
+ const headRows = wrapPlainText(tool.command, Math.max(1, contentWidth - 2));
1342
+ headRows.forEach((row, rowIndex) => {
1343
+ contentRows.push([
1344
+ createSpan(rowIndex === 0 ? `${icon} ` : " ", iconTone),
1345
+ createSpan(row || " ", "text"),
1346
+ ...(rowIndex === 0 && duration ? [createSpan(duration, "dim")] : []),
1347
+ ]);
1348
+ });
1349
+ if (tool.summary) {
1350
+ wrapPlainText(tool.summary, Math.max(1, contentWidth - 2)).forEach((row) => {
1351
+ contentRows.push([
1352
+ createSpan(" "),
1353
+ createSpan(row || " ", "muted"),
1354
+ ]);
1355
+ });
1356
+ }
1357
+ if (index < run.toolActivities.length - 1) {
1358
+ contentRows.push([createSpan("")]);
1359
+ }
1360
+ });
1361
+
1362
+ return buildDashCardRows({
1363
+ keyPrefix: `${item.key}-activity`,
1364
+ width,
1365
+ title: "Activity",
1366
+ rightBadge: "done",
1367
+ contentRows,
1368
+ });
1369
+ }
1370
+
1371
+ function buildActionRequiredRows(item: Extract<RenderTimelineItem, { type: "turn" }>, width: number): TimelineRow[] {
1372
+ const question = item.renderState.question;
1373
+ if (!question) return [];
1374
+
1375
+ const contentWidth = Math.max(1, width - 4);
1376
+ const wrappedQuestion = question
1377
+ .split("\n")
1378
+ .flatMap((line) => {
1379
+ const rows = wrapPlainText(line, contentWidth);
1380
+ return rows.length > 0 ? rows : [""];
1381
+ });
1382
+
1383
+ const rows: TimelineRow[] = [
1384
+ createRow(`${item.key}-question-top`, [createSpan(`┌${"─".repeat(Math.max(1, width - 2))}┐`, "borderActive")], width),
1385
+ ];
1386
+
1387
+ const title = `[${item.item.turnIndex}] ACTION REQUIRED`;
1388
+ const titleWidth = getTextWidth(title) + getTextWidth("⚡");
1389
+ const padding = Math.max(1, contentWidth - titleWidth);
1390
+ rows.push(createRow(
1391
+ `${item.key}-question-title`,
1392
+ [
1393
+ createSpan("│ ", "borderActive"),
1394
+ createSpan(title, "text", { bold: true }),
1395
+ createSpan(" ".repeat(padding)),
1396
+ createSpan("⚡", "text", { bold: true }),
1397
+ createSpan(" │", "borderActive"),
1398
+ ],
1399
+ width,
1400
+ ));
1401
+ rows.push(createBlankRow(`${item.key}-question-gap`, width));
1402
+ rows.push(createRow(
1403
+ `${item.key}-question-label`,
1404
+ [
1405
+ createSpan("│ ", "borderActive"),
1406
+ createSpan("Verification Question", "text", { bold: true }),
1407
+ createSpan(" ".repeat(Math.max(0, contentWidth - getTextWidth("Verification Question")))),
1408
+ createSpan(" │", "borderActive"),
1409
+ ],
1410
+ width,
1411
+ ));
1412
+
1413
+ wrappedQuestion.forEach((row, index) => {
1414
+ rows.push(createRow(
1415
+ `${item.key}-question-row-${index}`,
1416
+ [
1417
+ createSpan("│ ", "borderActive"),
1418
+ createSpan(row || " ", "text"),
1419
+ createSpan(" ".repeat(Math.max(0, contentWidth - getTextWidth(row || " ")))),
1420
+ createSpan(" │", "borderActive"),
1421
+ ],
1422
+ width,
1423
+ ));
1424
+ });
1425
+
1426
+ rows.push(createBlankRow(`${item.key}-question-end-gap`, width));
1427
+ rows.push(createRow(`${item.key}-question-bottom`, [createSpan(`└${"─".repeat(Math.max(1, width - 2))}┘`, "borderActive")], width));
1428
+ return rows;
1429
+ }
1430
+
1431
+ // ─── Standalone event & intro rows ───────────────────────────────────────────
1432
+
1433
+ export function buildStandaloneEventRows(item: Extract<RenderTimelineItem, { type: "event" }>, width: number): TimelineRow[] {
1434
+ const rows: TimelineRow[] = [];
1435
+ const event = item.event;
1436
+
1437
+ if (event.type === "shell") {
1438
+ const command = sanitizeTerminalOutput(event.command);
1439
+ const summary = sanitizeTerminalOutput(event.summary ?? "");
1440
+ const marker = event.status === "failed" ? "✕ " : "✧ ";
1441
+ const markerTone = event.status === "failed" ? "error" : "accent";
1442
+ const verb = event.status === "running"
1443
+ ? "Executing shell"
1444
+ : event.status === "completed"
1445
+ ? "Executed shell"
1446
+ : "Shell failed";
1447
+ const statusBits = [
1448
+ event.exitCode !== null && event.status !== "running" ? `exit ${event.exitCode}` : null,
1449
+ event.durationMs !== null ? `${(event.durationMs / 1000).toFixed(2)}s` : null,
1450
+ ].filter(Boolean).join(" • ");
1451
+ const heading = `${verb}: ${command}${statusBits ? ` • ${statusBits}` : ""}`;
1452
+
1453
+ rows.push(...buildPrefixedContentRows(
1454
+ `${item.key}-shell`,
1455
+ [createSpan(marker, markerTone)],
1456
+ [createSpan(" ", markerTone)],
1457
+ [createSpan(heading, "text")],
1458
+ width,
1459
+ ));
1460
+
1461
+ if (summary && event.status !== "running") {
1462
+ const summaryRows = wrapPlainText(summary, Math.max(1, width - 2));
1463
+ rows.push(...buildIndentedRows(
1464
+ `${item.key}-summary`,
1465
+ summaryRows.map((row) => [createSpan(row || " ", event.status === "failed" ? "error" : "muted")]),
1466
+ width,
1467
+ 2,
1468
+ ));
1469
+ }
1470
+
1471
+ if (event.status === "failed") {
1472
+ const failureExcerpt = getShellFailureExcerpt(event);
1473
+ rows.push(...buildIndentedRows(
1474
+ `${item.key}-stderr`,
1475
+ failureExcerpt.map((line) => [createSpan(line, "error")]),
1476
+ width,
1477
+ 2,
1478
+ ));
1479
+ }
1480
+
1481
+ return rows;
1482
+ }
1483
+
1484
+ if (event.type === "error") {
1485
+ rows.push(...buildPrefixedContentRows(
1486
+ `${item.key}-error`,
1487
+ [createSpan("✕ ", "error")],
1488
+ [createSpan(" ", "error")],
1489
+ [createSpan(sanitizeTerminalOutput(event.title), "error")],
1490
+ width,
1491
+ ));
1492
+
1493
+ // Show the full content — not just the first line. Error messages can span
1494
+ // multiple lines (stack traces, multi-step explanations) and silently
1495
+ // truncating to line 1 hides important diagnostic information.
1496
+ const errorContentLines = sanitizeTerminalOutput(event.content)
1497
+ .split("\n")
1498
+ .filter((line) => line.trim());
1499
+ if (errorContentLines.length > 0) {
1500
+ const wrappedRows = errorContentLines.flatMap((line) =>
1501
+ wrapPlainText(line, Math.max(1, width - 2)).map((row) => [createSpan(row || " ", "muted")])
1502
+ );
1503
+ rows.push(...buildIndentedRows(
1504
+ `${item.key}-error-content`,
1505
+ wrappedRows,
1506
+ width,
1507
+ 2,
1508
+ ));
1509
+ }
1510
+ return rows;
1511
+ }
1512
+
1513
+ rows.push(...buildPrefixedContentRows(
1514
+ `${item.key}-system`,
1515
+ [createSpan("• ", "info")],
1516
+ [createSpan(" ", "info")],
1517
+ [createSpan(sanitizeTerminalOutput(event.title), "text")],
1518
+ width,
1519
+ ));
1520
+
1521
+ // Show the full content — not just the first line. System events carry
1522
+ // rich multi-line payloads: /help output, auth status, model listings,
1523
+ // workspace summaries, etc. Limiting to line 1 silently hides all of it.
1524
+ const systemContentLines = sanitizeTerminalOutput(event.content)
1525
+ .split("\n")
1526
+ .filter((line) => line.trim());
1527
+ if (systemContentLines.length > 0) {
1528
+ const wrappedRows = systemContentLines.flatMap((line) =>
1529
+ wrapPlainText(line, Math.max(1, width - 2)).map((row) => [createSpan(row || " ", "dim")])
1530
+ );
1531
+ rows.push(...buildIndentedRows(
1532
+ `${item.key}-system-content`,
1533
+ wrappedRows,
1534
+ width,
1535
+ 2,
1536
+ ));
1537
+ }
1538
+
1539
+ return rows;
1540
+ }
1541
+
1542
+ export function buildIntroRows(item: Extract<RenderTimelineItem, { type: "intro" }>, width: number): TimelineRow[] {
1543
+ const rows: TimelineRow[] = [];
1544
+ const { intro } = item;
1545
+ const safeWidth = Math.max(10, width);
1546
+ const startupHeaderMode = intro.startupHeaderMode
1547
+ ?? (intro.layoutMode === "full" ? "large" : "compact");
1548
+ if (startupHeaderMode === "tiny") {
1549
+ const messageRows = [
1550
+ "Codexa",
1551
+ "Terminal is too small for the startup view.",
1552
+ "Resize the terminal to continue.",
1553
+ ];
1554
+ messageRows.forEach((line, index) => {
1555
+ rows.push(createRow(
1556
+ `${item.key}-resize-${index}`,
1557
+ [createSpan(clampVisualText(line, safeWidth), index === 0 ? "text" : "muted", { bold: index === 0 })],
1558
+ safeWidth,
1559
+ ));
1560
+ });
1561
+ return rows;
1562
+ }
1563
+
1564
+ const logoRows = startupHeaderMode === "large"
1565
+ ? INTRO_WORDMARK
1566
+ : ["CODEXA"];
1567
+ const logoWidth = logoRows.reduce((maxWidth, line) => Math.max(maxWidth, getTextWidth(line)), 0);
1568
+ const workspaceName = getWorkspaceDisplayName(intro.workspaceLabel);
1569
+ const metaLines = [
1570
+ `Codexa v${intro.version}`,
1571
+ `Auth: ${intro.authLabel}`,
1572
+ workspaceName ? `Workspace: ${workspaceName}` : null,
1573
+ ].filter((line): line is string => Boolean(line));
1574
+ const gapWidth = 2;
1575
+ const widestMetaLine = metaLines.reduce((maxWidth, line) => Math.max(maxWidth, getTextWidth(line)), 0);
1576
+ const canRenderSideBySide = metaLines.length > 0
1577
+ && safeWidth >= logoWidth + gapWidth + widestMetaLine;
1578
+
1579
+ if (canRenderSideBySide) {
1580
+ const metaStartRow = Math.max(0, Math.floor((logoRows.length - metaLines.length) / 2));
1581
+ const rowCount = Math.max(logoRows.length, metaStartRow + metaLines.length);
1582
+ const metaWidth = Math.max(1, safeWidth - logoWidth - gapWidth);
1583
+
1584
+ for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) {
1585
+ const logoLine = logoRows[rowIndex] ?? "";
1586
+ const logoPadding = Math.max(0, logoWidth - getTextWidth(logoLine));
1587
+ const metaIndex = rowIndex - metaStartRow;
1588
+ const metaLine = metaIndex >= 0 && metaIndex < metaLines.length
1589
+ ? sanitizeTerminalOutput(metaLines[metaIndex]!)
1590
+ : "";
1591
+ const spans = [
1592
+ createSpan(`${logoLine}${" ".repeat(logoPadding)}`, "accent", { bold: true }),
1593
+ createSpan(" ".repeat(gapWidth)),
1594
+ ];
1595
+
1596
+ if (metaLine) {
1597
+ spans.push(createSpan(clampVisualText(metaLine, metaWidth), metaIndex === 0 ? "text" : "muted", { bold: metaIndex === 0 }));
1598
+ }
1599
+
1600
+ rows.push(createRow(
1601
+ `${item.key}-intro-row-${rowIndex}`,
1602
+ spans,
1603
+ safeWidth,
1604
+ ));
1605
+ }
1606
+ } else {
1607
+ logoRows.forEach((line, index) => {
1608
+ rows.push(createRow(
1609
+ `${item.key}-logo-${index}`,
1610
+ [createSpan(clampVisualText(line, safeWidth), "accent", { bold: true })],
1611
+ safeWidth,
1612
+ ));
1613
+ });
1614
+
1615
+ metaLines.forEach((line, index) => {
1616
+ const wrapped = wrapPlainText(sanitizeTerminalOutput(line), safeWidth);
1617
+ wrapped.forEach((row, rowIndex) => {
1618
+ rows.push(createRow(
1619
+ `${item.key}-meta-${index}-${rowIndex}`,
1620
+ [createSpan(row || " ", index === 0 ? "text" : "muted", { bold: index === 0 })],
1621
+ safeWidth,
1622
+ ));
1623
+ });
1624
+ });
1625
+ }
1626
+
1627
+ rows.push(createBlankRow(`${item.key}-gap`, safeWidth));
1628
+ return rows;
1629
+ }
1630
+
1631
+ function getWorkspaceDisplayName(workspaceLabel: string): string {
1632
+ const sanitized = sanitizeTerminalOutput(workspaceLabel).trim();
1633
+ if (!sanitized) return "";
1634
+ const segments = sanitized.split(/[\\/]+/).map((segment) => segment.trim()).filter(Boolean);
1635
+ return segments[segments.length - 1] ?? sanitized;
1636
+ }
1637
+
1638
+ function applyTurnOpacity(rows: TimelineRow[], opacity: "active" | "recent" | "dim"): TimelineRow[] {
1639
+ if (opacity === "active") {
1640
+ return rows;
1641
+ }
1642
+
1643
+ if (opacity === "recent") {
1644
+ return rows.map((row) => {
1645
+ if (row.key.includes("-action-")) return row;
1646
+ return {
1647
+ ...row,
1648
+ spans: row.spans.map((span) => {
1649
+ if (span.tone === "borderActive") {
1650
+ return { ...span, tone: "borderSubtle" as TimelineTone };
1651
+ }
1652
+ return { ...span };
1653
+ }),
1654
+ };
1655
+ });
1656
+ }
1657
+
1658
+ return rows.map((row) => {
1659
+ if (row.key.includes("-action-")) return row;
1660
+ return {
1661
+ ...row,
1662
+ spans: row.spans.map((span) => {
1663
+ if (
1664
+ span.tone === "text"
1665
+ || span.tone === "muted"
1666
+ || span.tone === "info"
1667
+ || span.tone === "warning"
1668
+ ) {
1669
+ return { ...span, tone: "dim" as TimelineTone };
1670
+ }
1671
+ if (span.tone === "accent") {
1672
+ return { ...span, tone: "muted" as TimelineTone };
1673
+ }
1674
+ if (span.tone === "borderActive") {
1675
+ return { ...span, tone: "borderSubtle" as TimelineTone };
1676
+ }
1677
+ return { ...span };
1678
+ }),
1679
+ };
1680
+ });
1681
+ }
1682
+
1683
+
1684
+ // ─── Stream event types ───────────────────────────────────────────────────────
1685
+
1686
+ export type StreamEvent =
1687
+ | { kind: "thinking"; streamSeq: number; block: RunProgressBlock; isActive: boolean }
1688
+ | { kind: "response"; streamSeq: number; segment: RunResponseSegment }
1689
+ | { kind: "action"; streamSeq: number; tool: RunToolActivity }
1690
+ | { kind: "actionSummary"; streamSeq: number; id: string; label: string; count: number }
1691
+ | { kind: "plan"; streamSeq: number; planText: string; approved: boolean };
1692
+
1693
+ const ACTION_COMPACT_KEEP_HEAD = 2;
1694
+ const ACTION_COMPACT_KEEP_TAIL = 2;
1695
+ const ACTION_COMPACT_MIN_COUNT = ACTION_COMPACT_KEEP_HEAD + ACTION_COMPACT_KEEP_TAIL + 2;
1696
+
1697
+ function getCompactableActionLabel(event: StreamEvent): string | null {
1698
+ if (event.kind !== "action") return null;
1699
+ if (event.tool.status !== "completed") return null;
1700
+ const label = getFriendlyActionLabel(normalizeCommand(event.tool.command));
1701
+ return label === "Read file" || label === "List files" ? label : null;
1702
+ }
1703
+
1704
+ function compactActionBursts(events: StreamEvent[], verbose: boolean): StreamEvent[] {
1705
+ if (verbose) return events;
1706
+
1707
+ const compacted: StreamEvent[] = [];
1708
+ for (let index = 0; index < events.length;) {
1709
+ const label = getCompactableActionLabel(events[index]!);
1710
+ if (!label) {
1711
+ compacted.push(events[index]!);
1712
+ index += 1;
1713
+ continue;
1714
+ }
1715
+
1716
+ let end = index + 1;
1717
+ while (end < events.length && getCompactableActionLabel(events[end]!) === label) {
1718
+ end += 1;
1719
+ }
1720
+
1721
+ const group = events.slice(index, end);
1722
+ if (group.length < ACTION_COMPACT_MIN_COUNT) {
1723
+ compacted.push(...group);
1724
+ index = end;
1725
+ continue;
1726
+ }
1727
+
1728
+ const hidden = group.slice(ACTION_COMPACT_KEEP_HEAD, group.length - ACTION_COMPACT_KEEP_TAIL);
1729
+ compacted.push(...group.slice(0, ACTION_COMPACT_KEEP_HEAD));
1730
+ compacted.push({
1731
+ kind: "actionSummary",
1732
+ streamSeq: hidden[0]?.streamSeq ?? group[ACTION_COMPACT_KEEP_HEAD]?.streamSeq ?? group[0]!.streamSeq,
1733
+ id: `${label.toLowerCase().replace(/\s+/g, "-")}-${group[0]!.streamSeq}-${group[group.length - 1]!.streamSeq}`,
1734
+ label,
1735
+ count: hidden.length,
1736
+ });
1737
+ compacted.push(...group.slice(group.length - ACTION_COMPACT_KEEP_TAIL));
1738
+ index = end;
1739
+ }
1740
+
1741
+ return compacted;
1742
+ }
1743
+
1744
+ // ─── Codex stream block builders ─────────────────────────────────────────────
1745
+
1746
+ function buildCodexPlainRows(
1747
+ keyPrefix: string,
1748
+ width: number,
1749
+ contentRows: TimelineRowSpan[][],
1750
+ ): TimelineRow[] {
1751
+ const indent = " ".repeat(transcriptContentIndent);
1752
+ const rows: TimelineRow[] = [
1753
+ createRow(`${keyPrefix}-label`, [createSpan(indent), createSpan("Codexa", "muted", { bold: true })], width),
1754
+ ];
1755
+
1756
+ contentRows.forEach((row, index) => {
1757
+ rows.push(createRow(`${keyPrefix}-content-${index}`, [createSpan(indent), ...(row.length > 0 ? row : [createSpan(" ")])], width));
1758
+ });
1759
+
1760
+ return rows;
1761
+ }
1762
+
1763
+ function buildCodexThinkingRows(params: {
1764
+ keyPrefix: string;
1765
+ width: number;
1766
+ event: Extract<StreamEvent, { kind: "thinking" }>;
1767
+ isLive: boolean;
1768
+ verbose: boolean;
1769
+ }): TimelineRow[] {
1770
+ renderDebug.traceRender("ThinkingBlock", params.event.block.status, {
1771
+ keyPrefix: params.keyPrefix,
1772
+ streamSeq: params.event.streamSeq,
1773
+ isLive: params.isLive,
1774
+ textLength: params.event.block.text.length,
1775
+ });
1776
+
1777
+ const block = params.event.block;
1778
+ const cacheKey = rowCacheKey([
1779
+ "thinking",
1780
+ params.keyPrefix,
1781
+ block.id,
1782
+ block.status,
1783
+ block.updatedAt,
1784
+ textCacheToken(block.text),
1785
+ params.width,
1786
+ params.verbose,
1787
+ params.isLive,
1788
+ params.event.isActive,
1789
+ ]);
1790
+
1791
+ return getCachedStreamingBlockRows(cacheKey, () => {
1792
+ const contentRows: TimelineRowSpan[][] = [];
1793
+ const contentWidth = Math.max(1, params.width - transcriptContentIndent);
1794
+ const bodyLines = formatProgressBlockBodyLines(params.event.block.text, contentWidth);
1795
+ const lineCap = params.verbose ? bodyLines.length : COMPACT_PROCESSING_BODY_LINE_CAP;
1796
+ const visibleBodyLines = bodyLines.slice(0, lineCap);
1797
+ const overflowCount = bodyLines.length - visibleBodyLines.length;
1798
+
1799
+ visibleBodyLines.forEach((line) => {
1800
+ contentRows.push([createSpan(line || " ", "dim")]);
1801
+ });
1802
+
1803
+ if (overflowCount > 0) {
1804
+ contentRows.push([
1805
+ createSpan(`… (${overflowCount} more line${overflowCount === 1 ? "" : "s"})`, "dim"),
1806
+ ]);
1807
+ }
1808
+
1809
+ if (params.isLive && params.event.isActive) {
1810
+ contentRows.push([createSpan("▌", "accent")]);
1811
+ }
1812
+
1813
+ return buildCodexPlainRows(params.keyPrefix, params.width, contentRows);
1814
+ });
1815
+ }
1816
+
1817
+ function actionDisplayToken(descriptor: ActionDisplayDescriptor): string {
1818
+ return rowCacheKey([
1819
+ descriptor.id,
1820
+ descriptor.status,
1821
+ descriptor.label,
1822
+ descriptor.command,
1823
+ descriptor.duration,
1824
+ descriptor.summary,
1825
+ descriptor.icon,
1826
+ descriptor.iconTone,
1827
+ descriptor.showLiveCursor,
1828
+ descriptor.borderTone,
1829
+ descriptor.width,
1830
+ descriptor.verbose,
1831
+ ]);
1832
+ }
1833
+
1834
+ function getActionDisplayDescriptor(params: {
1835
+ keyPrefix: string;
1836
+ tool: RunToolActivity;
1837
+ width: number;
1838
+ verbose: boolean;
1839
+ isLive: boolean;
1840
+ borderTone: TimelineTone;
1841
+ }): ActionDisplayDescriptor {
1842
+ const command = normalizeCommand(params.tool.command);
1843
+ const label = getFriendlyActionLabel(command);
1844
+ const duration = params.tool.completedAt != null
1845
+ ? ` ${formatDuration(params.tool.completedAt - params.tool.startedAt)}`
1846
+ : "";
1847
+ const summary = params.verbose ? params.tool.summary ?? "" : "";
1848
+ const showLiveCursor = params.isLive && params.tool.status === "running";
1849
+ const descriptor: ActionDisplayDescriptor = {
1850
+ id: params.tool.id,
1851
+ status: params.tool.status,
1852
+ label,
1853
+ command,
1854
+ duration,
1855
+ summary,
1856
+ icon: params.tool.status === "failed" ? "✕" : params.tool.status === "completed" ? "✓" : "•",
1857
+ iconTone: params.tool.status === "failed" ? "error" : params.tool.status === "completed" ? "success" : "info",
1858
+ showLiveCursor,
1859
+ borderTone: params.borderTone,
1860
+ width: params.width,
1861
+ verbose: params.verbose,
1862
+ };
1863
+ const cacheKey = `${params.keyPrefix}:${params.tool.id}`;
1864
+ const cached = _actionDisplayCache.get(cacheKey);
1865
+ if (cached && actionDisplayToken(cached) === actionDisplayToken(descriptor)) {
1866
+ return cached;
1867
+ }
1868
+ _actionDisplayCache.set(cacheKey, descriptor);
1869
+ return descriptor;
1870
+ }
1871
+
1872
+ function buildPlainActionDebugRows(params: {
1873
+ keyPrefix: string;
1874
+ width: number;
1875
+ descriptor: ActionDisplayDescriptor;
1876
+ }): TimelineRow[] {
1877
+ const statusText = params.descriptor.label
1878
+ ? `${params.descriptor.label}: ${params.descriptor.command}`
1879
+ : params.descriptor.command;
1880
+ const suffix = params.descriptor.duration ? params.descriptor.duration : "";
1881
+ const text = clampVisualText(`${params.descriptor.icon} ${statusText}${suffix}`, Math.max(1, params.width - 1));
1882
+ renderDebug.traceEvent("action", "plainActionRow", {
1883
+ actionId: params.descriptor.id,
1884
+ status: params.descriptor.status,
1885
+ keyPrefix: params.keyPrefix,
1886
+ width: params.width,
1887
+ });
1888
+ return [
1889
+ createRow(
1890
+ `${params.keyPrefix}-plain`,
1891
+ [
1892
+ createSpan(text || " ", params.descriptor.iconTone),
1893
+ ],
1894
+ params.width,
1895
+ ),
1896
+ ];
1897
+ }
1898
+
1899
+ export function buildActionEventRows(params: {
1900
+ keyPrefix: string;
1901
+ width: number;
1902
+ event: Extract<StreamEvent, { kind: "action" }>;
1903
+ borderTone: TimelineTone;
1904
+ verbose: boolean;
1905
+ isLive: boolean;
1906
+ }): TimelineRow[] {
1907
+ const tool = params.event.tool;
1908
+ const descriptor = getActionDisplayDescriptor({
1909
+ keyPrefix: params.keyPrefix,
1910
+ tool,
1911
+ width: params.width,
1912
+ verbose: params.verbose,
1913
+ isLive: params.isLive,
1914
+ borderTone: params.borderTone,
1915
+ });
1916
+ renderDebug.traceRender("ActionLog", params.event.tool.status, {
1917
+ keyPrefix: params.keyPrefix,
1918
+ streamSeq: params.event.streamSeq,
1919
+ isLive: params.isLive,
1920
+ commandLength: params.event.tool.command.length,
1921
+ displayedToken: actionDisplayToken(descriptor),
1922
+ });
1923
+
1924
+ if (renderDebug.isPlainActionsDebugEnabled()) {
1925
+ return buildPlainActionDebugRows({
1926
+ keyPrefix: params.keyPrefix,
1927
+ width: params.width,
1928
+ descriptor,
1929
+ });
1930
+ }
1931
+
1932
+ const cacheKey = rowCacheKey([
1933
+ "action",
1934
+ params.keyPrefix,
1935
+ actionDisplayToken(descriptor),
1936
+ ]);
1937
+
1938
+ const isCompleted = tool.status !== "running";
1939
+ if (isCompleted) {
1940
+ const cached = _completedActionRowCache.get(cacheKey);
1941
+ const completedActionTokenKey = `${params.keyPrefix}:${tool.id}`;
1942
+ const displayedToken = actionDisplayToken(descriptor);
1943
+ const previousCompletedToken = _completedActionTokenById.get(completedActionTokenKey);
1944
+ if (previousCompletedToken && previousCompletedToken !== displayedToken) {
1945
+ renderDebug.traceEvent("action", "completedSnapshotInvalidation", {
1946
+ actionId: tool.id,
1947
+ status: tool.status,
1948
+ rowKey: params.keyPrefix,
1949
+ });
1950
+ }
1951
+ renderDebug.traceFlickerEvent("actionRowBuild", {
1952
+ cache: cached ? "hit-completed" : "miss-completed",
1953
+ actionId: tool.id,
1954
+ status: tool.status,
1955
+ rowKey: params.keyPrefix,
1956
+ displayedToken,
1957
+ });
1958
+ if (cached) return cached;
1959
+ } else {
1960
+ const cached = _streamingBlockRowCache.get(cacheKey);
1961
+ renderDebug.traceFlickerEvent("actionRowBuild", {
1962
+ cache: cached ? "hit-streaming" : "miss-streaming",
1963
+ actionId: tool.id,
1964
+ status: tool.status,
1965
+ rowKey: params.keyPrefix,
1966
+ displayedToken: actionDisplayToken(descriptor),
1967
+ });
1968
+ }
1969
+
1970
+ const buildActionRows = () => {
1971
+ const contentWidth = Math.max(1, params.width - 4);
1972
+ const commandWidth = Math.max(1, contentWidth - 2);
1973
+ const detailText = descriptor.showLiveCursor
1974
+ ? "▌"
1975
+ : descriptor.summary.trim()
1976
+ ? descriptor.summary
1977
+ : " ";
1978
+ const contentRows: TimelineRowSpan[][] = [];
1979
+
1980
+ if (descriptor.label) {
1981
+ contentRows.push([
1982
+ createSpan(`${descriptor.icon} `, descriptor.iconTone),
1983
+ createSpan(descriptor.label, "text"),
1984
+ ...(descriptor.duration ? [createSpan(descriptor.duration, "dim")] : []),
1985
+ ]);
1986
+ wrapPlainText(descriptor.command, commandWidth).forEach((row) => {
1987
+ contentRows.push([
1988
+ createSpan(" "),
1989
+ createSpan(row || " ", "muted"),
1990
+ ]);
1991
+ });
1992
+ } else {
1993
+ const headRows = wrapPlainText(descriptor.command, commandWidth);
1994
+ headRows.forEach((row, rowIndex) => {
1995
+ contentRows.push([
1996
+ createSpan(rowIndex === 0 ? `${descriptor.icon} ` : " ", rowIndex === 0 ? descriptor.iconTone : undefined),
1997
+ createSpan(row || " ", "text"),
1998
+ ...(rowIndex === 0 && descriptor.duration ? [createSpan(descriptor.duration, "dim")] : []),
1999
+ ]);
2000
+ });
2001
+ }
2002
+
2003
+ if (!descriptor.label) {
2004
+ contentRows.push([
2005
+ createSpan(" "),
2006
+ createSpan(" ", "muted"),
2007
+ ]);
2008
+ }
2009
+ contentRows.push([
2010
+ createSpan(" "),
2011
+ createSpan(detailText, descriptor.showLiveCursor ? "accent" : "muted"),
2012
+ ]);
2013
+
2014
+ return buildDashCardRows({
2015
+ keyPrefix: params.keyPrefix,
2016
+ width: params.width,
2017
+ title: "action",
2018
+ borderTone: descriptor.borderTone,
2019
+ contentRows: contentRows.length > 0 ? contentRows : [[createSpan(" ")]],
2020
+ });
2021
+ };
2022
+
2023
+ if (isCompleted) {
2024
+ const rows = buildActionRows();
2025
+ _completedActionRowCache.set(cacheKey, rows);
2026
+ _completedActionTokenById.set(`${params.keyPrefix}:${tool.id}`, actionDisplayToken(descriptor));
2027
+ return rows;
2028
+ }
2029
+
2030
+ return getCachedStreamingBlockRows(cacheKey, buildActionRows);
2031
+ }
2032
+
2033
+ function buildActionSummaryRows(params: {
2034
+ keyPrefix: string;
2035
+ width: number;
2036
+ event: Extract<StreamEvent, { kind: "actionSummary" }>;
2037
+ borderTone: TimelineTone;
2038
+ }): TimelineRow[] {
2039
+ const label = params.event.label === "Read file" ? "read activity" : "list activity";
2040
+ const cacheKey = rowCacheKey([
2041
+ "action-summary",
2042
+ params.keyPrefix,
2043
+ params.width,
2044
+ params.event.id,
2045
+ params.event.label,
2046
+ params.event.count,
2047
+ params.borderTone,
2048
+ ]);
2049
+
2050
+ return getCachedFrozenRows(cacheKey, () => buildDashCardRows({
2051
+ keyPrefix: params.keyPrefix,
2052
+ width: params.width,
2053
+ title: "action",
2054
+ borderTone: params.borderTone,
2055
+ contentRows: [[
2056
+ createSpan("✓ ", "success"),
2057
+ createSpan(`${params.event.count} repeated ${label}`, "text"),
2058
+ createSpan(" summarized", "dim"),
2059
+ ]],
2060
+ }));
2061
+ }
2062
+
2063
+ function buildCodexResponseRows(params: {
2064
+ keyPrefix: string;
2065
+ width: number;
2066
+ run: RunEvent;
2067
+ event: Extract<StreamEvent, { kind: "response" }>;
2068
+ streaming: boolean;
2069
+ isLastEvent: boolean;
2070
+ isLive: boolean;
2071
+ verbose: boolean;
2072
+ }): TimelineRow[] {
2073
+ renderDebug.traceRender("ActiveMessage", params.event.segment.status, {
2074
+ keyPrefix: params.keyPrefix,
2075
+ streamSeq: params.event.streamSeq,
2076
+ streaming: params.streaming,
2077
+ isLive: params.isLive,
2078
+ chunkCount: params.event.segment.chunks.length,
2079
+ textLength: getResponseSegmentText(params.event.segment).length,
2080
+ });
2081
+
2082
+ const segmentText = getResponseSegmentText(params.event.segment);
2083
+ const segmentStreaming = params.event.segment.status === "active";
2084
+
2085
+ const buildRows = (): TimelineRow[] => {
2086
+ let responseRows: TimelineRowSpan[][] = [];
2087
+ const contentWidth = Math.max(1, params.width - transcriptContentIndent);
2088
+ const rawContent = splitSentenceWall(formatTerminalAnswerInline(segmentText));
2089
+
2090
+ if (!params.streaming) _streamingRowCache = null;
2091
+ const sanitized = segmentStreaming ? sanitizeStreamChunk(rawContent) : sanitizeOutput(rawContent);
2092
+ const normalized = normalizeOutput(sanitized);
2093
+ const segments = formatForBox(classifyOutput(normalized), contentWidth);
2094
+ responseRows = buildMarkdownRows(segments, contentWidth);
2095
+
2096
+ if (!params.streaming && params.run.status === "failed" && params.isLastEvent) {
2097
+ const failureMessage = sanitizeTerminalOutput(params.run.errorMessage ?? params.run.summary);
2098
+ const failureRows: TimelineRowSpan[][] = [];
2099
+ wrapPlainText(failureMessage, Math.max(1, contentWidth - 2)).forEach((row, index) => {
2100
+ failureRows.push([
2101
+ createSpan(index === 0 ? "✕ " : " ", "error"),
2102
+ createSpan(row || " ", "error"),
2103
+ ]);
2104
+ });
2105
+ responseRows = [...failureRows, ...responseRows];
2106
+ }
2107
+
2108
+ if (segmentStreaming && !params.verbose && responseRows.length > COMPACT_STREAMING_TAIL_CAP) {
2109
+ const hiddenRowCount = responseRows.length - COMPACT_STREAMING_TAIL_CAP;
2110
+ responseRows = [
2111
+ [createSpan(`… (${hiddenRowCount} line${hiddenRowCount === 1 ? "" : "s"} above)`, "dim")],
2112
+ ...responseRows.slice(-COMPACT_STREAMING_TAIL_CAP),
2113
+ ];
2114
+ }
2115
+
2116
+ return buildCodexPlainRows(params.keyPrefix, params.width, responseRows);
2117
+ };
2118
+
2119
+ if (!segmentStreaming) {
2120
+ const failureMessage = !params.streaming && params.run.status === "failed" && params.isLastEvent
2121
+ ? params.run.errorMessage ?? params.run.summary
2122
+ : "";
2123
+ const cacheKey = rowCacheKey([
2124
+ "response",
2125
+ params.keyPrefix,
2126
+ params.event.segment.id,
2127
+ params.event.segment.status,
2128
+ textCacheToken(segmentText),
2129
+ params.width,
2130
+ params.verbose,
2131
+ params.streaming,
2132
+ params.run.status,
2133
+ params.isLastEvent,
2134
+ textCacheToken(failureMessage),
2135
+ ]);
2136
+ return getCachedStreamingBlockRows(cacheKey, buildRows);
2137
+ }
2138
+
2139
+ return buildRows();
2140
+ }
2141
+
2142
+ // ─── Plan & unified stream rendering ─────────────────────────────────────────
2143
+
2144
+ function buildApprovedPlanRows(params: {
2145
+ keyPrefix: string;
2146
+ width: number;
2147
+ planText: string;
2148
+ approved: boolean;
2149
+ workspaceRoot?: string | null;
2150
+ }): TimelineRow[] {
2151
+ const contentWidth = Math.max(1, params.width - 4);
2152
+ const normalized = normalizePlanReviewMarkdown(params.planText, params.workspaceRoot);
2153
+ const classified = classifyOutput(normalized);
2154
+ const formatted = formatForBox(classified, contentWidth);
2155
+ const contentRows = buildMarkdownRows(formatted, contentWidth);
2156
+
2157
+ return buildDashCardRows({
2158
+ keyPrefix: params.keyPrefix,
2159
+ width: params.width,
2160
+ title: "Plan",
2161
+ rightBadge: params.approved ? "approved" : undefined,
2162
+ borderTone: "accent",
2163
+ titleTone: "text",
2164
+ badgeTone: "success",
2165
+ contentRows,
2166
+ });
2167
+ }
2168
+
2169
+ function buildUnifiedStreamRows(item: Extract<RenderTimelineItem, { type: "turn" }>, width: number, options: { verbose?: boolean; workspaceRoot?: string | null }): TimelineRow[] {
2170
+ const run = item.item.run!;
2171
+ const assistant = item.item.assistant;
2172
+ const streaming = item.renderState.runPhase === "streaming";
2173
+ const dim = item.renderState.opacity !== "active";
2174
+ const borderTone = dim ? "borderSubtle" : streaming ? "borderActive" : "borderSubtle";
2175
+ const actionBorderTone = item.renderState.opacity === "dim" ? "borderSubtle" : "borderActive";
2176
+ const verbose = options.verbose ?? false;
2177
+ const events = compactActionBursts(collectStreamEvents(item, streaming), verbose);
2178
+
2179
+ const rows: TimelineRow[] = [];
2180
+
2181
+ events.forEach((event, index) => {
2182
+ const isLastEvent = index === events.length - 1;
2183
+ const isLive = run.status === "running" && isLastEvent; // The cursor is on the last event
2184
+
2185
+ if (index > 0) {
2186
+ rows.push(createBlankRow(`${item.key}-stream-gap-${index}`, width));
2187
+ }
2188
+
2189
+ if (event.kind === "thinking") {
2190
+ rows.push(...buildCodexThinkingRows({
2191
+ keyPrefix: `${item.key}-codex-thinking-${event.streamSeq}`,
2192
+ width,
2193
+ event,
2194
+ isLive,
2195
+ verbose,
2196
+ }));
2197
+ } else if (event.kind === "action") {
2198
+ rows.push(...buildActionEventRows({
2199
+ keyPrefix: `${item.key}-action-${event.streamSeq}`,
2200
+ width,
2201
+ event,
2202
+ borderTone: actionBorderTone,
2203
+ verbose,
2204
+ isLive,
2205
+ }));
2206
+ } else if (event.kind === "actionSummary") {
2207
+ rows.push(...buildActionSummaryRows({
2208
+ keyPrefix: `${item.key}-action-summary-${event.streamSeq}`,
2209
+ width,
2210
+ event,
2211
+ borderTone: actionBorderTone,
2212
+ }));
2213
+ } else if (event.kind === "response") {
2214
+ rows.push(...buildCodexResponseRows({
2215
+ keyPrefix: `${item.key}-codex-response-${event.streamSeq}`,
2216
+ width,
2217
+ run,
2218
+ event,
2219
+ streaming,
2220
+ isLastEvent,
2221
+ isLive,
2222
+ verbose,
2223
+ }));
2224
+ } else if (event.kind === "plan") {
2225
+ rows.push(...buildApprovedPlanRows({
2226
+ keyPrefix: `${item.key}-plan-${event.streamSeq}`,
2227
+ width,
2228
+ planText: event.planText,
2229
+ approved: event.approved,
2230
+ workspaceRoot: options.workspaceRoot,
2231
+ }));
2232
+ }
2233
+ });
2234
+
2235
+ if (!streaming && run.status !== "running") {
2236
+ if (run.status === "canceled") {
2237
+ rows.push(createBlankRow(`${item.key}-cancel-gap`, width));
2238
+ rows.push(...buildCodexPlainRows(
2239
+ `${item.key}-cancel`,
2240
+ width,
2241
+ wrapPlainText(sanitizeTerminalOutput(run.summary), width).map((wrapped) => [createSpan(wrapped || " ", "warning")]),
2242
+ ));
2243
+ } else if (
2244
+ run.status === "completed"
2245
+ && !events.some((event) => event.kind === "response" && getResponseSegmentText(event.segment).trim())
2246
+ ) {
2247
+ // Keep empty completed turns quiet.
2248
+ }
2249
+
2250
+ if (run.truncatedOutput) {
2251
+ rows.push(...buildCodexPlainRows(`${item.key}-truncated`, width, [[createSpan(RUN_OUTPUT_TRUNCATION_NOTICE, "dim")]]));
2252
+ }
2253
+
2254
+ if (verbose) {
2255
+ if (run.touchedFileCount > 0) {
2256
+ const fileScanRows = buildFileScanRows(item, width);
2257
+ if (fileScanRows.length > 0) {
2258
+ rows.push(createBlankRow(`${item.key}-files-gap`, width));
2259
+ rows.push(...fileScanRows);
2260
+ }
2261
+ }
2262
+ } else {
2263
+ const impactRows = buildImpactSummaryRows(item, width);
2264
+ if (impactRows.length > 0) {
2265
+ rows.push(createBlankRow(`${item.key}-impact-gap`, width));
2266
+ rows.push(...impactRows);
2267
+ }
2268
+ }
2269
+ }
2270
+
2271
+ return rows;
2272
+ }
2273
+
2274
+ function collectStreamEvents(
2275
+ item: Extract<RenderTimelineItem, { type: "turn" }>,
2276
+ streaming: boolean,
2277
+ ): StreamEvent[] {
2278
+ const run = item.item.run!;
2279
+ const assistant = item.item.assistant;
2280
+ const blocksById = new Map<string, RunProgressBlock>();
2281
+ for (const entry of run.progressEntries ?? []) {
2282
+ for (const block of entry.blocks) blocksById.set(block.id, block);
2283
+ }
2284
+ const toolsById = new Map(run.toolActivities.map((tool) => [tool.id, tool] as const));
2285
+ const segmentsById = new Map((run.responseSegments ?? []).map((seg) => [seg.id, seg] as const));
2286
+
2287
+ const events: StreamEvent[] = [];
2288
+ const sortedItems = (run.streamItems ?? []).slice().sort((a, b) => a.streamSeq - b.streamSeq);
2289
+ for (const it of sortedItems) {
2290
+ if (it.kind === "thinking") {
2291
+ const block = blocksById.get(it.refId);
2292
+ if (block && block.text.trim().length > 0 && !(run.status === "running" && block.status === "active")) {
2293
+ events.push({
2294
+ kind: "thinking",
2295
+ streamSeq: it.streamSeq,
2296
+ block,
2297
+ isActive: run.status === "running" && block.status === "active",
2298
+ });
2299
+ }
2300
+ } else if (it.kind === "action") {
2301
+ const tool = toolsById.get(it.refId);
2302
+ if (tool) events.push({ kind: "action", streamSeq: it.streamSeq, tool });
2303
+ } else if (it.kind === "response") {
2304
+ const segment = segmentsById.get(it.refId);
2305
+ if (segment) events.push({ kind: "response", streamSeq: it.streamSeq, segment });
2306
+ } else if (it.kind === "plan") {
2307
+ const planText = run.plan?.id === it.refId
2308
+ ? getRunPlanText(run.plan)
2309
+ : run.approvedPlan ?? "";
2310
+ if (planText.trim()) {
2311
+ events.push({
2312
+ kind: "plan",
2313
+ streamSeq: it.streamSeq,
2314
+ planText,
2315
+ approved: Boolean(run.approvedPlan),
2316
+ });
2317
+ }
2318
+ }
2319
+ }
2320
+
2321
+ // Backward-compat fallback for older session data that predates streamItems.
2322
+ // New runs always use the streamItems path above.
2323
+ if (events.length === 0 && sortedItems.length === 0) {
2324
+ let legacySeq = 0;
2325
+ for (const entry of run.progressEntries ?? []) {
2326
+ if (!VISIBLE_THINKING_SOURCES.has(entry.source)) continue;
2327
+ for (const block of entry.blocks) {
2328
+ if (!block.text.trim()) continue;
2329
+ if (run.status === "running" && block.status === "active") continue;
2330
+ legacySeq += 1;
2331
+ events.push({
2332
+ kind: "thinking",
2333
+ streamSeq: legacySeq,
2334
+ block,
2335
+ isActive: run.status === "running" && block.status === "active",
2336
+ });
2337
+ }
2338
+ }
2339
+
2340
+ for (const tool of run.toolActivities ?? []) {
2341
+ legacySeq += 1;
2342
+ events.push({ kind: "action", streamSeq: legacySeq, tool });
2343
+ }
2344
+
2345
+ for (const segment of run.responseSegments ?? []) {
2346
+ if (!getResponseSegmentText(segment).trim() && !streaming) continue;
2347
+ legacySeq += 1;
2348
+ events.push({ kind: "response", streamSeq: legacySeq, segment });
2349
+ }
2350
+ }
2351
+
2352
+ // First-render fallback: nothing resolvable yet but assistant text exists.
2353
+ if (events.length === 0 && (getAssistantContent(assistant).length > 0 || streaming)) {
2354
+ const synthetic: RunResponseSegment = {
2355
+ id: `synthetic-${run.id}`,
2356
+ streamSeq: 1,
2357
+ chunks: [getAssistantContent(assistant)],
2358
+ status: streaming ? "active" : "completed",
2359
+ startedAt: run.startedAt,
2360
+ };
2361
+ events.push({ kind: "response", streamSeq: 1, segment: synthetic });
2362
+ }
2363
+
2364
+ return events;
2365
+ }
2366
+
2367
+ // ─── Turn assembly & static caching ──────────────────────────────────────────
2368
+
2369
+ function buildTurnRows(
2370
+ item: Extract<RenderTimelineItem, { type: "turn" }>,
2371
+ width: number,
2372
+ options: { verbose?: boolean; workspaceRoot?: string | null } = {},
2373
+ ): TimelineRow[] {
2374
+ const verbose = options.verbose ?? false;
2375
+ const rows: TimelineRow[] = [];
2376
+
2377
+ rows.push(...buildUserInputRows(item, width));
2378
+ rows.push(createBlankRow(`${item.key}-prompt-gap`, width));
2379
+
2380
+ if (item.item.run) {
2381
+ rows.push(...buildUnifiedStreamRows(item, width, options));
2382
+ }
2383
+
2384
+ rows.push(...buildActionRequiredRows(item, width));
2385
+ rows.push(createBlankRow(`${item.key}-turn-end-gap`, width));
2386
+ return applyTurnOpacity(rows, item.renderState.opacity);
2387
+ }
2388
+
2389
+ function wrapRows(
2390
+ rows: TimelineRow[],
2391
+ totalWidth: number,
2392
+ padded: boolean,
2393
+ keyPrefix: string,
2394
+ includeMargin: boolean,
2395
+ ): TimelineRow[] {
2396
+ const leftPad = padded ? 1 : 0;
2397
+ const innerWidth = Math.max(1, totalWidth - (leftPad * 2));
2398
+ const prefixedRows = rows.map((row) => {
2399
+ const cacheKey = `${keyPrefix}:${row.key}:${totalWidth}:${innerWidth}:${leftPad}`;
2400
+ let rowCache = _wrappedRowCache.get(row);
2401
+ if (!rowCache) {
2402
+ rowCache = new Map<string, TimelineRow>();
2403
+ _wrappedRowCache.set(row, rowCache);
2404
+ }
2405
+
2406
+ const cached = rowCache.get(cacheKey);
2407
+ if (cached) return cached;
2408
+
2409
+ const wrapped = createRow(
2410
+ `${keyPrefix}-wrapped-${row.key}`,
2411
+ [
2412
+ ...(leftPad > 0 ? [createSpan(" ".repeat(leftPad))] : []),
2413
+ ...padSpansToWidth(row.spans, innerWidth),
2414
+ ...(leftPad > 0 ? [createSpan(" ".repeat(leftPad))] : []),
2415
+ ],
2416
+ totalWidth,
2417
+ );
2418
+ rowCache.set(cacheKey, wrapped);
2419
+ return wrapped;
2420
+ });
2421
+
2422
+ if (includeMargin) {
2423
+ const marginKey = `${keyPrefix}:${totalWidth}:margin`;
2424
+ let margin = _wrappedBlankRowCache.get(marginKey);
2425
+ if (!margin) {
2426
+ margin = createBlankRow(`${keyPrefix}-margin`, totalWidth);
2427
+ _wrappedBlankRowCache.set(marginKey, margin);
2428
+ }
2429
+ prefixedRows.push(margin);
2430
+ }
2431
+ return prefixedRows;
2432
+ }
2433
+
2434
+ function wrapItemRows(rows: TimelineRow[], totalWidth: number, padded: boolean, keyPrefix: string): TimelineRow[] {
2435
+ return wrapRows(rows, totalWidth, padded, keyPrefix, true);
2436
+ }
2437
+
2438
+ function rowsToSnapshot(items: BuiltTimelineItem[]): TimelineSnapshot {
2439
+ const rows = items.flatMap((item) => item.rows);
2440
+ return {
2441
+ items,
2442
+ rows,
2443
+ totalRows: rows.length,
2444
+ itemCount: items.length,
2445
+ };
2446
+ }
2447
+
2448
+ function buildStableEventRows(item: Extract<RenderTimelineItem, { type: "event" }>, innerWidth: number): TimelineRow[] {
2449
+ const cacheKey = rowCacheKey([
2450
+ "stable-event",
2451
+ item.key,
2452
+ item.event.type,
2453
+ item.event.id,
2454
+ innerWidth,
2455
+ textCacheToken("title" in item.event ? item.event.title : item.event.command),
2456
+ textCacheToken("content" in item.event ? item.event.content : item.event.summary ?? ""),
2457
+ "status" in item.event ? item.event.status : "",
2458
+ "durationMs" in item.event ? item.event.durationMs : "",
2459
+ ]);
2460
+ return getCachedFrozenRows(cacheKey, () => buildStandaloneEventRows(item, innerWidth));
2461
+ }
2462
+
2463
+ function buildStableIntroRows(item: Extract<RenderTimelineItem, { type: "intro" }>, innerWidth: number): TimelineRow[] {
2464
+ const cacheKey = rowCacheKey([
2465
+ "stable-intro",
2466
+ item.key,
2467
+ innerWidth,
2468
+ item.intro.version,
2469
+ item.intro.layoutMode,
2470
+ item.intro.authLabel,
2471
+ textCacheToken(item.intro.workspaceLabel),
2472
+ ]);
2473
+ return getCachedFrozenRows(cacheKey, () => buildIntroRows(item, innerWidth));
2474
+ }
2475
+
2476
+ function buildPlanCacheSignature(run: RunEvent | null | undefined): string {
2477
+ if (!run) return "";
2478
+ const plan = run.plan;
2479
+ return rowCacheKey([
2480
+ "plan",
2481
+ plan?.id ?? "",
2482
+ plan?.status ?? "",
2483
+ plan?.streamSeq ?? "",
2484
+ (plan?.chunks ?? []).map((chunk) => textCacheToken(chunk)),
2485
+ textCacheToken(run.approvedPlan),
2486
+ (run.streamItems ?? []).map((item) => `${item.streamSeq}:${item.kind}:${item.refId}`),
2487
+ ]);
2488
+ }
2489
+
2490
+ function buildStableFrozenTurnRows(
2491
+ item: Extract<RenderTimelineItem, { type: "turn" }>,
2492
+ innerWidth: number,
2493
+ options: { verbose?: boolean; workspaceRoot?: string | null },
2494
+ ): TimelineRow[] {
2495
+ const verbose = options.verbose ?? false;
2496
+ const run = item.item.run;
2497
+ const user = item.item.user;
2498
+ const cacheKey = rowCacheKey([
2499
+ "stable-turn",
2500
+ item.key,
2501
+ innerWidth,
2502
+ verbose,
2503
+ item.renderState.opacity,
2504
+ item.renderState.runPhase,
2505
+ user?.id,
2506
+ textCacheToken(user?.prompt),
2507
+ run?.id,
2508
+ run?.status,
2509
+ buildPlanCacheSignature(run),
2510
+ run?.durationMs,
2511
+ textCacheToken(run?.summary),
2512
+ textCacheToken(item.item.assistant?.content),
2513
+ textCacheToken(item.item.assistant?.contentChunks.join("")),
2514
+ run?.toolActivities.map((tool) => `${tool.id}:${tool.status}:${tool.startedAt}:${tool.completedAt ?? ""}:${textCacheToken(tool.command)}:${textCacheToken(tool.summary)}`).join("|"),
2515
+ run?.responseSegments?.map((segment) => `${segment.id}:${segment.status}:${textCacheToken(getResponseSegmentText(segment))}`).join("|"),
2516
+ run?.progressEntries.map((entry) => `${entry.id}:${entry.blocks.map((block) => `${block.id}:${block.status}:${block.updatedAt}:${textCacheToken(block.text)}`).join(",")}`).join("|"),
2517
+ options.workspaceRoot ?? "",
2518
+ ]);
2519
+ return getCachedFrozenRows(cacheKey, () => buildTurnRows(item, innerWidth, options));
2520
+ }
2521
+
2522
+ function isLiveStreamEvent(event: StreamEvent, run: RunEvent): boolean {
2523
+ if (run.status !== "running") return false;
2524
+ if (event.kind === "response") return event.segment.status === "active";
2525
+ if (event.kind === "action") return event.tool.status === "running";
2526
+ if (event.kind === "actionSummary") return false;
2527
+ return false;
2528
+ }
2529
+
2530
+ function buildStableActiveTurnGroups(
2531
+ item: Extract<RenderTimelineItem, { type: "turn" }>,
2532
+ innerWidth: number,
2533
+ options: { verbose?: boolean; workspaceRoot?: string | null },
2534
+ ): { frozenRows: TimelineRow[]; liveRows: TimelineRow[] } {
2535
+ const verbose = options.verbose ?? false;
2536
+ const run = item.item.run;
2537
+ if (!run || (item.renderState.runPhase !== "streaming" && item.renderState.runPhase !== "thinking")) {
2538
+ return {
2539
+ frozenRows: buildStableFrozenTurnRows(item, innerWidth, options),
2540
+ liveRows: [],
2541
+ };
2542
+ }
2543
+
2544
+ const streaming = item.renderState.runPhase === "streaming";
2545
+ const dim = item.renderState.opacity !== "active";
2546
+ const borderTone = dim ? "borderSubtle" : streaming ? "borderActive" : "borderSubtle";
2547
+ const actionBorderTone = item.renderState.opacity === "dim" ? "borderSubtle" : "borderActive";
2548
+ const events = compactActionBursts(collectStreamEvents(item, streaming), verbose);
2549
+ let orderedRows = [...getCachedFrozenRows(rowCacheKey([
2550
+ "stable-active-user",
2551
+ item.key,
2552
+ innerWidth,
2553
+ item.renderState.opacity,
2554
+ textCacheToken(item.item.user?.prompt),
2555
+ ]), () => buildUserInputRows(item, innerWidth))];
2556
+ orderedRows.push(createBlankRow(`${item.key}-active-prompt-gap`, innerWidth));
2557
+
2558
+ events.forEach((event, index) => {
2559
+ const liveEvent = isLiveStreamEvent(event, run);
2560
+ const targetRows: TimelineRow[] = [];
2561
+ const isLastEvent = index === events.length - 1;
2562
+
2563
+ if (index > 0) {
2564
+ targetRows.push(createBlankRow(`${item.key}-stream-gap-${index}`, innerWidth));
2565
+ }
2566
+
2567
+ if (event.kind === "thinking") {
2568
+ const build = () => buildCodexThinkingRows({
2569
+ keyPrefix: `${item.key}-codex-thinking-${event.streamSeq}`,
2570
+ width: innerWidth,
2571
+ event,
2572
+ isLive: liveEvent,
2573
+ verbose,
2574
+ });
2575
+ targetRows.push(...(liveEvent ? build() : getCachedFrozenRows(rowCacheKey([
2576
+ "stable-thinking",
2577
+ item.key,
2578
+ innerWidth,
2579
+ verbose,
2580
+ event.block.id,
2581
+ event.block.status,
2582
+ event.block.updatedAt,
2583
+ textCacheToken(event.block.text),
2584
+ ]), build)));
2585
+ } else if (event.kind === "action") {
2586
+ const build = () => buildActionEventRows({
2587
+ keyPrefix: `${item.key}-action-${event.streamSeq}`,
2588
+ width: innerWidth,
2589
+ event,
2590
+ borderTone: actionBorderTone,
2591
+ verbose,
2592
+ isLive: liveEvent,
2593
+ });
2594
+ targetRows.push(...(liveEvent ? build() : getCachedFrozenRows(rowCacheKey([
2595
+ "stable-action",
2596
+ item.key,
2597
+ innerWidth,
2598
+ verbose,
2599
+ event.tool.id,
2600
+ event.tool.status,
2601
+ event.tool.startedAt,
2602
+ event.tool.completedAt ?? "",
2603
+ textCacheToken(event.tool.command),
2604
+ ]), build)));
2605
+ } else if (event.kind === "actionSummary") {
2606
+ targetRows.push(...buildActionSummaryRows({
2607
+ keyPrefix: `${item.key}-action-summary-${event.streamSeq}`,
2608
+ width: innerWidth,
2609
+ event,
2610
+ borderTone: actionBorderTone,
2611
+ }));
2612
+ } else if (event.kind === "response") {
2613
+ const build = () => buildCodexResponseRows({
2614
+ keyPrefix: `${item.key}-codex-response-${event.streamSeq}`,
2615
+ width: innerWidth,
2616
+ run,
2617
+ event,
2618
+ streaming,
2619
+ isLastEvent,
2620
+ isLive: liveEvent,
2621
+ verbose,
2622
+ });
2623
+ targetRows.push(...(liveEvent ? build() : getCachedFrozenRows(rowCacheKey([
2624
+ "stable-response",
2625
+ item.key,
2626
+ innerWidth,
2627
+ verbose,
2628
+ run.status,
2629
+ event.segment.id,
2630
+ event.segment.status,
2631
+ textCacheToken(getResponseSegmentText(event.segment)),
2632
+ ]), build)));
2633
+ } else if (event.kind === "plan") {
2634
+ const build = () => buildApprovedPlanRows({
2635
+ keyPrefix: `${item.key}-plan-${event.streamSeq}`,
2636
+ width: innerWidth,
2637
+ planText: event.planText,
2638
+ approved: event.approved,
2639
+ workspaceRoot: options.workspaceRoot,
2640
+ });
2641
+ targetRows.push(...getCachedFrozenRows(rowCacheKey([
2642
+ "stable-plan",
2643
+ item.key,
2644
+ innerWidth,
2645
+ textCacheToken(event.planText),
2646
+ event.approved ? "approved" : "draft",
2647
+ options.workspaceRoot ?? "",
2648
+ ]), build));
2649
+ }
2650
+
2651
+ orderedRows = [...orderedRows, ...targetRows];
2652
+ });
2653
+
2654
+ const questionRows = buildActionRequiredRows(item, innerWidth);
2655
+ if (questionRows.length > 0) {
2656
+ orderedRows = [...orderedRows, ...questionRows];
2657
+ }
2658
+
2659
+ orderedRows.push(createBlankRow(`${item.key}-active-turn-end-gap`, innerWidth));
2660
+
2661
+ return {
2662
+ frozenRows: applyTurnOpacity(orderedRows, item.renderState.opacity),
2663
+ liveRows: [],
2664
+ };
2665
+ }
2666
+
2667
+ // ─── Native transcript builders ───────────────────────────────────────────────
2668
+
2669
+ function isNativeLiveStreamEvent(event: StreamEvent, run: RunEvent): boolean {
2670
+ if (run.status !== "running") return false;
2671
+ if (event.kind === "action") return event.tool.status === "running";
2672
+ if (event.kind === "response") return event.segment.id === (run.activeResponseSegmentId ?? null);
2673
+ if (event.kind === "plan") return run.plan?.status === "active";
2674
+ return false;
2675
+ }
2676
+
2677
+ function buildNativeStreamEventRows(params: {
2678
+ item: Extract<RenderTimelineItem, { type: "turn" }>;
2679
+ event: StreamEvent;
2680
+ eventIndex: number;
2681
+ innerWidth: number;
2682
+ verbose: boolean;
2683
+ workspaceRoot?: string | null;
2684
+ forceStable?: boolean;
2685
+ }): TimelineRow[] {
2686
+ const { item, event, eventIndex, innerWidth, verbose } = params;
2687
+ const run = item.item.run!;
2688
+ const streaming = item.renderState.runPhase === "streaming";
2689
+ const actionBorderTone = item.renderState.opacity === "dim" ? "borderSubtle" : "borderActive";
2690
+ const rows: TimelineRow[] = [];
2691
+
2692
+ if (eventIndex > 0) {
2693
+ rows.push(createBlankRow(`${item.key}-stream-gap-${event.streamSeq}`, innerWidth));
2694
+ }
2695
+
2696
+ if (event.kind === "thinking") {
2697
+ rows.push(...buildCodexThinkingRows({
2698
+ keyPrefix: `${item.key}-codex-thinking-${event.streamSeq}`,
2699
+ width: innerWidth,
2700
+ event,
2701
+ isLive: !params.forceStable && isNativeLiveStreamEvent(event, run),
2702
+ verbose,
2703
+ }));
2704
+ } else if (event.kind === "action") {
2705
+ rows.push(...buildActionEventRows({
2706
+ keyPrefix: `${item.key}-action-${event.streamSeq}`,
2707
+ width: innerWidth,
2708
+ event,
2709
+ borderTone: actionBorderTone,
2710
+ verbose,
2711
+ isLive: !params.forceStable && isNativeLiveStreamEvent(event, run),
2712
+ }));
2713
+ } else if (event.kind === "actionSummary") {
2714
+ rows.push(...buildActionSummaryRows({
2715
+ keyPrefix: `${item.key}-action-summary-${event.streamSeq}`,
2716
+ width: innerWidth,
2717
+ event,
2718
+ borderTone: actionBorderTone,
2719
+ }));
2720
+ } else if (event.kind === "response") {
2721
+ const stableEvent = params.forceStable
2722
+ ? { ...event, segment: { ...event.segment, status: "completed" as const } }
2723
+ : event;
2724
+ rows.push(...buildCodexResponseRows({
2725
+ keyPrefix: `${item.key}-codex-response-${event.streamSeq}`,
2726
+ width: innerWidth,
2727
+ run,
2728
+ event: stableEvent,
2729
+ streaming,
2730
+ isLastEvent: false,
2731
+ isLive: !params.forceStable && isNativeLiveStreamEvent(event, run),
2732
+ verbose,
2733
+ }));
2734
+ } else if (event.kind === "plan") {
2735
+ rows.push(...buildApprovedPlanRows({
2736
+ keyPrefix: `${item.key}-plan-${event.streamSeq}`,
2737
+ width: innerWidth,
2738
+ planText: event.planText,
2739
+ approved: event.approved,
2740
+ workspaceRoot: params.workspaceRoot,
2741
+ }));
2742
+ }
2743
+
2744
+ return rows;
2745
+ }
2746
+
2747
+ function wrapNativeRows(
2748
+ rows: TimelineRow[],
2749
+ totalWidth: number,
2750
+ padded: boolean,
2751
+ keyPrefix: string,
2752
+ ): TimelineRow[] {
2753
+ return wrapRows(rows, totalWidth, padded, keyPrefix, false);
2754
+ }
2755
+
2756
+ function appendNativeTurnParts(
2757
+ output: NativeTranscriptParts,
2758
+ item: Extract<RenderTimelineItem, { type: "turn" }>,
2759
+ options: {
2760
+ totalWidth: number;
2761
+ verboseMode?: boolean;
2762
+ workspaceRoot?: string | null;
2763
+ },
2764
+ ): void {
2765
+ const run = item.item.run;
2766
+ const innerWidth = Math.max(10, options.totalWidth - (item.padded ? 2 : 0));
2767
+ const verbose = options.verboseMode ?? false;
2768
+
2769
+ if (item.item.user) {
2770
+ output.staticItems.push({
2771
+ key: `${item.key}-user`,
2772
+ rows: wrapNativeRows(
2773
+ buildUserInputRows(item, innerWidth),
2774
+ options.totalWidth,
2775
+ item.padded,
2776
+ item.key,
2777
+ ),
2778
+ });
2779
+ output.staticItems.push({
2780
+ key: `${item.key}-prompt-gap`,
2781
+ rows: [createBlankRow(`${item.key}-prompt-gap-row`, options.totalWidth)],
2782
+ });
2783
+ }
2784
+
2785
+ if (!run) return;
2786
+
2787
+ const events = compactActionBursts(
2788
+ collectStreamEvents(item, item.renderState.runPhase === "streaming"),
2789
+ verbose,
2790
+ );
2791
+
2792
+ events.forEach((event, eventIndex) => {
2793
+ // Placement: keep ALL events in liveRows while the run is active so Ink's
2794
+ // <Static> does not grow mid-generation (which shifts the viewport).
2795
+ // Only after run.status flips away from "running" do events go to staticItems.
2796
+ const placeAsLive = run.status === "running";
2797
+ // Rendering: only the event that is currently active gets a live indicator
2798
+ // (spinner / streaming cursor). Completed events render in stable form even
2799
+ // while their parent run is still running.
2800
+ const isLiveRender = placeAsLive && isNativeLiveStreamEvent(event, run);
2801
+ const rows = buildNativeStreamEventRows({
2802
+ item,
2803
+ event,
2804
+ eventIndex,
2805
+ innerWidth,
2806
+ verbose,
2807
+ workspaceRoot: options.workspaceRoot,
2808
+ forceStable: !isLiveRender,
2809
+ });
2810
+
2811
+ const wrappedRows = wrapNativeRows(rows, options.totalWidth, item.padded, item.key);
2812
+ if (placeAsLive) {
2813
+ output.liveRows.push(...wrappedRows);
2814
+ } else {
2815
+ output.staticItems.push({
2816
+ key: `${item.key}-stream-${event.streamSeq}`,
2817
+ rows: wrappedRows,
2818
+ });
2819
+ }
2820
+ });
2821
+
2822
+ const questionRows = buildActionRequiredRows(item, innerWidth);
2823
+ if (questionRows.length > 0) {
2824
+ output.liveRows.push(...wrapNativeRows(questionRows, options.totalWidth, item.padded, item.key));
2825
+ }
2826
+
2827
+ const endGapRow = createBlankRow(`${item.key}-turn-end-gap-row`, options.totalWidth);
2828
+ if (run && run.status === "running") {
2829
+ output.liveRows.push(endGapRow);
2830
+ } else {
2831
+ output.staticItems.push({
2832
+ key: `${item.key}-turn-end-gap`,
2833
+ rows: [endGapRow],
2834
+ });
2835
+ }
2836
+ }
2837
+
2838
+ export function buildNativeTranscriptParts(
2839
+ items: RenderTimelineItem[],
2840
+ options: {
2841
+ totalWidth: number;
2842
+ verboseMode?: boolean;
2843
+ debugLabel?: string;
2844
+ workspaceRoot?: string | null;
2845
+ },
2846
+ ): NativeTranscriptParts {
2847
+ renderDebug.traceEvent("timeline", "buildNativeTranscriptParts", {
2848
+ debugLabel: options.debugLabel ?? "native",
2849
+ items: items.length,
2850
+ totalWidth: options.totalWidth,
2851
+ verbose: options.verboseMode ?? false,
2852
+ });
2853
+
2854
+ const output: NativeTranscriptParts = {
2855
+ staticItems: [],
2856
+ liveRows: [],
2857
+ };
2858
+
2859
+ for (const item of items) {
2860
+ if (item.type === "turn") {
2861
+ appendNativeTurnParts(output, item, options);
2862
+ continue;
2863
+ }
2864
+
2865
+ output.staticItems.push({
2866
+ key: item.key,
2867
+ rows: buildTimelineSnapshot([item], options).rows,
2868
+ });
2869
+ }
2870
+
2871
+ return output;
2872
+ }
2873
+
2874
+ // ─── Public snapshot builders ─────────────────────────────────────────────────
2875
+
2876
+ export function buildStableTimelineSnapshot(
2877
+ items: RenderTimelineItem[],
2878
+ options: {
2879
+ totalWidth: number;
2880
+ verboseMode?: boolean;
2881
+ debugLabel?: string;
2882
+ workspaceRoot?: string | null;
2883
+ },
2884
+ ): StableTimelineSnapshot {
2885
+ const verbose = options.verboseMode ?? false;
2886
+ renderDebug.traceFlickerEvent("snapshotBuild", {
2887
+ reason: options.debugLabel ?? "stable",
2888
+ items: items.length,
2889
+ totalWidth: options.totalWidth,
2890
+ verbose,
2891
+ stable: true,
2892
+ });
2893
+
2894
+ const builtItems: BuiltTimelineItem[] = [];
2895
+ const frozenRows: TimelineRow[] = [];
2896
+ const liveRows: TimelineRow[] = [];
2897
+
2898
+ for (const item of items) {
2899
+ const innerWidth = Math.max(10, options.totalWidth - (item.padded ? 2 : 0));
2900
+ let itemFrozenRows: TimelineRow[];
2901
+ let itemLiveRows: TimelineRow[];
2902
+
2903
+ if (item.type === "intro") {
2904
+ itemFrozenRows = buildStableIntroRows(item, innerWidth);
2905
+ itemLiveRows = [];
2906
+ } else if (item.type === "event") {
2907
+ itemFrozenRows = buildStableEventRows(item, innerWidth);
2908
+ itemLiveRows = [];
2909
+ } else {
2910
+ const groups = buildStableActiveTurnGroups(item, innerWidth, {
2911
+ verbose,
2912
+ workspaceRoot: options.workspaceRoot,
2913
+ });
2914
+ itemFrozenRows = groups.frozenRows;
2915
+ itemLiveRows = groups.liveRows;
2916
+ }
2917
+
2918
+ const hasLiveRows = itemLiveRows.length > 0;
2919
+ const wrappedFrozenRows = wrapRows(itemFrozenRows, options.totalWidth, item.padded, item.key, !hasLiveRows);
2920
+ const wrappedLiveRows = hasLiveRows
2921
+ ? wrapRows(itemLiveRows, options.totalWidth, item.padded, item.key, true)
2922
+ : [];
2923
+ const rows = [...wrappedFrozenRows, ...wrappedLiveRows];
2924
+ frozenRows.push(...wrappedFrozenRows);
2925
+ liveRows.push(...wrappedLiveRows);
2926
+ builtItems.push({
2927
+ key: item.key,
2928
+ rows,
2929
+ rowCount: rows.length,
2930
+ });
2931
+ }
2932
+
2933
+ return {
2934
+ snapshot: rowsToSnapshot(builtItems),
2935
+ frozenRows,
2936
+ liveRows,
2937
+ };
2938
+ }
2939
+
2940
+ export function buildTimelineSnapshot(
2941
+ items: RenderTimelineItem[],
2942
+ options: {
2943
+ totalWidth: number;
2944
+ verboseMode?: boolean;
2945
+ debugLabel?: string;
2946
+ workspaceRoot?: string | null;
2947
+ },
2948
+ ): TimelineSnapshot {
2949
+ const verbose = options.verboseMode ?? false;
2950
+ renderDebug.traceEvent("timeline", "buildSnapshot", {
2951
+ items: items.length,
2952
+ totalWidth: options.totalWidth,
2953
+ verbose,
2954
+ });
2955
+ renderDebug.traceFlickerEvent("snapshotBuild", {
2956
+ reason: options.debugLabel ?? "unknown",
2957
+ items: items.length,
2958
+ totalWidth: options.totalWidth,
2959
+ verbose,
2960
+ });
2961
+
2962
+ const builtItems = items.map((item) => {
2963
+ const innerWidth = Math.max(10, options.totalWidth - (item.padded ? 2 : 0));
2964
+
2965
+ let builtRows: TimelineRow[];
2966
+
2967
+ if (item.type === "intro") {
2968
+ const cacheKey = `i:${item.key}:${innerWidth}:${item.intro.version}:${item.intro.layoutMode}:${item.intro.startupHeaderMode ?? ""}:${item.intro.authLabel}:${item.intro.workspaceLabel}`;
2969
+ const cached = _staticRowCache.get(cacheKey);
2970
+ if (cached) {
2971
+ renderDebug.traceEvent("timeline", "rowGeneration", {
2972
+ itemKey: item.key,
2973
+ itemType: "intro",
2974
+ cache: "hit",
2975
+ innerWidth,
2976
+ });
2977
+ renderDebug.traceEvent("timeline", "staticCacheHit", { cacheKey, itemType: "intro" });
2978
+ builtRows = cached;
2979
+ } else {
2980
+ renderDebug.traceEvent("timeline", "rowGeneration", {
2981
+ itemKey: item.key,
2982
+ itemType: "intro",
2983
+ cache: "miss",
2984
+ innerWidth,
2985
+ });
2986
+ renderDebug.traceEvent("timeline", "staticCacheMiss", { cacheKey, itemType: "intro" });
2987
+ const r = buildIntroRows(item, innerWidth);
2988
+ _staticRowCache.set(cacheKey, r);
2989
+ builtRows = r;
2990
+ }
2991
+ } else if (item.type === "event") {
2992
+ // Standalone events (system, error, etc.) are immutable — cache by key+width.
2993
+ const cacheKey = `e:${item.key}:${innerWidth}`;
2994
+ const cached = _staticRowCache.get(cacheKey);
2995
+ if (cached) {
2996
+ renderDebug.traceEvent("timeline", "rowGeneration", {
2997
+ itemKey: item.key,
2998
+ itemType: "event",
2999
+ cache: "hit",
3000
+ innerWidth,
3001
+ });
3002
+ renderDebug.traceEvent("timeline", "staticCacheHit", { cacheKey, itemType: "event" });
3003
+ builtRows = cached;
3004
+ } else {
3005
+ renderDebug.traceEvent("timeline", "rowGeneration", {
3006
+ itemKey: item.key,
3007
+ itemType: "event",
3008
+ cache: "miss",
3009
+ innerWidth,
3010
+ });
3011
+ renderDebug.traceEvent("timeline", "staticCacheMiss", { cacheKey, itemType: "event" });
3012
+ const r = buildStandaloneEventRows(item, innerWidth);
3013
+ _staticRowCache.set(cacheKey, r);
3014
+ builtRows = r;
3015
+ }
3016
+ } else {
3017
+ const { runPhase, opacity } = item.renderState;
3018
+ // Only cache completed turns (runPhase "none"/"final") at a stable
3019
+ // opacity. Streaming and thinking items change every tick and use the
3020
+ // _streamingRowCache instead.
3021
+ const cacheable = runPhase !== "streaming" && runPhase !== "thinking";
3022
+ if (cacheable) {
3023
+ const cacheKey = rowCacheKey([
3024
+ "turn",
3025
+ item.key,
3026
+ innerWidth,
3027
+ verbose,
3028
+ runPhase,
3029
+ opacity,
3030
+ options.workspaceRoot ?? "",
3031
+ buildPlanCacheSignature(item.item.run),
3032
+ ]);
3033
+ const cached = _staticRowCache.get(cacheKey);
3034
+ if (cached) {
3035
+ renderDebug.traceEvent("timeline", "rowGeneration", {
3036
+ itemKey: item.key,
3037
+ itemType: "turn",
3038
+ runPhase,
3039
+ opacity,
3040
+ cache: "hit",
3041
+ innerWidth,
3042
+ });
3043
+ renderDebug.traceEvent("timeline", "staticCacheHit", { cacheKey, itemType: "turn", runPhase, opacity });
3044
+ builtRows = cached;
3045
+ } else {
3046
+ renderDebug.traceEvent("timeline", "rowGeneration", {
3047
+ itemKey: item.key,
3048
+ itemType: "turn",
3049
+ runPhase,
3050
+ opacity,
3051
+ cache: "miss",
3052
+ innerWidth,
3053
+ });
3054
+ renderDebug.traceEvent("timeline", "staticCacheMiss", { cacheKey, itemType: "turn", runPhase, opacity });
3055
+ const r = buildTurnRows(item, innerWidth, {
3056
+ verbose,
3057
+ workspaceRoot: options.workspaceRoot,
3058
+ });
3059
+ _staticRowCache.set(cacheKey, r);
3060
+ builtRows = r;
3061
+ }
3062
+ } else {
3063
+ renderDebug.traceEvent("timeline", "rowGeneration", {
3064
+ itemKey: item.key,
3065
+ itemType: "turn",
3066
+ runPhase,
3067
+ opacity,
3068
+ cache: "active",
3069
+ innerWidth,
3070
+ });
3071
+ renderDebug.traceEvent("timeline", "activeBuild", { itemKey: item.key, runPhase, opacity });
3072
+ builtRows = buildTurnRows(item, innerWidth, {
3073
+ verbose,
3074
+ workspaceRoot: options.workspaceRoot,
3075
+ });
3076
+ }
3077
+ }
3078
+
3079
+ const rows = wrapItemRows(builtRows, options.totalWidth, item.padded, item.key);
3080
+ return {
3081
+ key: item.key,
3082
+ rows,
3083
+ rowCount: rows.length,
3084
+ };
3085
+ });
3086
+
3087
+ return rowsToSnapshot(builtItems);
3088
+ }