ubume 0.1.0

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