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.
- package/README.md +88 -0
- package/bin/codexa-local-harness-bridge.js +3 -0
- package/bin/codexa.js +28 -0
- package/bin/ubume-local-harness-bridge.js +325 -0
- package/bin/ubume.js +398 -0
- package/package.json +66 -0
- package/src/app.tsx +5759 -0
- package/src/commands/handler.ts +889 -0
- package/src/config/appVersion.ts +69 -0
- package/src/config/buildInfo.ts +3 -0
- package/src/config/launchArgs.ts +196 -0
- package/src/config/layeredConfig.ts +853 -0
- package/src/config/legacyEnv.ts +16 -0
- package/src/config/persistence.ts +377 -0
- package/src/config/runtimeConfig.ts +558 -0
- package/src/config/settings.ts +405 -0
- package/src/config/toml-serialize.ts +109 -0
- package/src/config/trustStore.ts +84 -0
- package/src/config/updateCheckCache.ts +85 -0
- package/src/core/README.md +55 -0
- package/src/core/agent/loop.ts +464 -0
- package/src/core/agent/protocol.ts +345 -0
- package/src/core/agent/tools.ts +423 -0
- package/src/core/auth/codexAuth.ts +359 -0
- package/src/core/codex/codexExecArgs.ts +166 -0
- package/src/core/codex/codexLaunch.ts +163 -0
- package/src/core/codex/codexPrompt.ts +429 -0
- package/src/core/debug/inputDebug.ts +51 -0
- package/src/core/debug/localStreamDebug.ts +50 -0
- package/src/core/debug/modelStateDebug.ts +35 -0
- package/src/core/executables/antigravityExecutable.ts +48 -0
- package/src/core/executables/claudeExecutable.ts +63 -0
- package/src/core/executables/codexExecutable.ts +160 -0
- package/src/core/executables/executableResolver.ts +164 -0
- package/src/core/executables/geminiExecutable.ts +78 -0
- package/src/core/models/codexCapabilities.ts +97 -0
- package/src/core/models/codexModelCapabilities.ts +624 -0
- package/src/core/models/codexModelsCacheSeed.ts +153 -0
- package/src/core/models/modelSpecs.ts +303 -0
- package/src/core/models/providerModelCache.ts +94 -0
- package/src/core/perf/profiler.ts +125 -0
- package/src/core/perf/renderDebug.ts +398 -0
- package/src/core/process/CommandRunner.ts +280 -0
- package/src/core/process/processValidation.ts +111 -0
- package/src/core/providerLauncher/launcher.ts +220 -0
- package/src/core/providerLauncher/registry.ts +354 -0
- package/src/core/providerLauncher/types.ts +95 -0
- package/src/core/providerLauncher/workspaceConfig.ts +487 -0
- package/src/core/providerRuntime/anthropic.ts +580 -0
- package/src/core/providerRuntime/antigravity.ts +500 -0
- package/src/core/providerRuntime/capabilityProfile.ts +383 -0
- package/src/core/providerRuntime/claudeCodeDiscovery.ts +724 -0
- package/src/core/providerRuntime/claudeCodeDiscoveryDebug.ts +55 -0
- package/src/core/providerRuntime/codexaCupy.ts +97 -0
- package/src/core/providerRuntime/codexaNative.ts +425 -0
- package/src/core/providerRuntime/contextMetadata.ts +397 -0
- package/src/core/providerRuntime/gemini.ts +789 -0
- package/src/core/providerRuntime/lmstudio.ts +118 -0
- package/src/core/providerRuntime/local.ts +770 -0
- package/src/core/providerRuntime/localHarness/runtime.ts +1090 -0
- package/src/core/providerRuntime/localOutputBudget.ts +17 -0
- package/src/core/providerRuntime/mistralVibe.ts +667 -0
- package/src/core/providerRuntime/models.ts +175 -0
- package/src/core/providerRuntime/reasoning.ts +20 -0
- package/src/core/providerRuntime/registry.ts +286 -0
- package/src/core/providerRuntime/types.ts +142 -0
- package/src/core/providerRuntime/unsloth.ts +216 -0
- package/src/core/providers/codexJsonStream.ts +305 -0
- package/src/core/providers/codexSubprocess.ts +378 -0
- package/src/core/providers/codexTranscript.ts +695 -0
- package/src/core/providers/openaiNative.ts +13 -0
- package/src/core/providers/registry.ts +21 -0
- package/src/core/providers/types.ts +91 -0
- package/src/core/shared/attachments.ts +101 -0
- package/src/core/shared/cleanupFastFail.ts +67 -0
- package/src/core/shared/clipboard.ts +24 -0
- package/src/core/shared/clipboardImage.ts +111 -0
- package/src/core/shared/githubDiagnostics.ts +222 -0
- package/src/core/shared/hollowResponseFormat.ts +39 -0
- package/src/core/terminal/clearFrameBoundary.ts +852 -0
- package/src/core/terminal/frameLock.ts +110 -0
- package/src/core/terminal/inkRenderReset.ts +123 -0
- package/src/core/terminal/startupClear.ts +20 -0
- package/src/core/terminal/terminalCapabilities.ts +100 -0
- package/src/core/terminal/terminalControl.ts +169 -0
- package/src/core/terminal/terminalSanitize.ts +147 -0
- package/src/core/terminal/terminalTitle.ts +400 -0
- package/src/core/version/channel.ts +27 -0
- package/src/core/version/packageManager.ts +119 -0
- package/src/core/version/updateCheck.ts +203 -0
- package/src/core/workspace/appData.ts +107 -0
- package/src/core/workspace/conversationStore.ts +335 -0
- package/src/core/workspace/launchContext.ts +259 -0
- package/src/core/workspace/planStorage.ts +135 -0
- package/src/core/workspace/projectInstructions.ts +54 -0
- package/src/core/workspace/scratchDir.ts +64 -0
- package/src/core/workspace/workspaceActivity.ts +384 -0
- package/src/core/workspace/workspaceGuard.ts +377 -0
- package/src/core/workspace/workspaceRoot.ts +47 -0
- package/src/exec.ts +73 -0
- package/src/headless/execArgs.ts +296 -0
- package/src/headless/execRunner.ts +304 -0
- package/src/index.tsx +270 -0
- package/src/legacyEnvBootstrap.ts +5 -0
- package/src/session/appSession.ts +771 -0
- package/src/session/chatLifecycle.ts +994 -0
- package/src/session/conversation.ts +107 -0
- package/src/session/liveRenderScheduler.ts +214 -0
- package/src/session/persistedResponse.ts +93 -0
- package/src/session/planFlow.ts +159 -0
- package/src/session/planTranscript.ts +19 -0
- package/src/session/promptRunSchedule.ts +26 -0
- package/src/session/types.ts +234 -0
- package/src/test/runtimeTestUtils.ts +14 -0
- package/src/types/react-dom.d.ts +3 -0
- package/src/ui/chrome/ActivityBars.tsx +68 -0
- package/src/ui/chrome/ActivityIndicator.tsx +58 -0
- package/src/ui/chrome/AnimatedStatusText.tsx +69 -0
- package/src/ui/chrome/AppShell.tsx +474 -0
- package/src/ui/chrome/BottomComposer.tsx +1135 -0
- package/src/ui/chrome/DashCard.tsx +82 -0
- package/src/ui/chrome/RunFooter.tsx +65 -0
- package/src/ui/chrome/RuntimeStatusBar.tsx +108 -0
- package/src/ui/chrome/Spinner.tsx +25 -0
- package/src/ui/chrome/TopHeader.tsx +439 -0
- package/src/ui/chrome/UpdateAvailableCard.tsx +42 -0
- package/src/ui/chrome/busyStatusAnimation.ts +11 -0
- package/src/ui/input/commandNormalize.ts +66 -0
- package/src/ui/input/focus.ts +73 -0
- package/src/ui/input/imageAttachments.ts +18 -0
- package/src/ui/input/inputBuffer.ts +203 -0
- package/src/ui/input/pastedContent.ts +75 -0
- package/src/ui/input/rawArrowKeys.ts +28 -0
- package/src/ui/input/slashCommands.ts +43 -0
- package/src/ui/input/useStdinRawModeLease.ts +23 -0
- package/src/ui/layout.ts +560 -0
- package/src/ui/panels/AttachmentImportPanel.tsx +131 -0
- package/src/ui/panels/AuthPanel.tsx +149 -0
- package/src/ui/panels/BackendPicker.tsx +28 -0
- package/src/ui/panels/ModePicker.tsx +31 -0
- package/src/ui/panels/ModelPicker.tsx +31 -0
- package/src/ui/panels/ModelPickerScreen.tsx +761 -0
- package/src/ui/panels/ModelReasoningPicker.tsx +458 -0
- package/src/ui/panels/Panel.tsx +51 -0
- package/src/ui/panels/PermissionsPanel.tsx +78 -0
- package/src/ui/panels/PlanActionPicker.tsx +187 -0
- package/src/ui/panels/ProviderPicker.tsx +753 -0
- package/src/ui/panels/ProviderSetupPrompt.tsx +52 -0
- package/src/ui/panels/ReasoningPicker.tsx +46 -0
- package/src/ui/panels/ResumePicker.tsx +90 -0
- package/src/ui/panels/SelectionPanel.tsx +138 -0
- package/src/ui/panels/SettingsPanel.tsx +156 -0
- package/src/ui/panels/TextEntryPanel.tsx +139 -0
- package/src/ui/panels/ThemePicker.tsx +32 -0
- package/src/ui/panels/ToolApprovalPanel.tsx +46 -0
- package/src/ui/panels/UpdatePromptPanel.tsx +236 -0
- package/src/ui/panels/responsivePickerViewport.ts +64 -0
- package/src/ui/render/Markdown.tsx +331 -0
- package/src/ui/render/diffRenderer.ts +116 -0
- package/src/ui/render/logoVariants.ts +113 -0
- package/src/ui/render/modeDisplay.ts +52 -0
- package/src/ui/render/outputPipeline.ts +64 -0
- package/src/ui/render/runtimeDisplay.ts +128 -0
- package/src/ui/render/terminalAnswerFormat.ts +128 -0
- package/src/ui/render/textLayout.ts +392 -0
- package/src/ui/theme.tsx +274 -0
- package/src/ui/themeFlow.ts +41 -0
- package/src/ui/timeline/ActionRequiredBlock.tsx +38 -0
- package/src/ui/timeline/AgentBlock.tsx +130 -0
- package/src/ui/timeline/StaticIntroItem.tsx +54 -0
- package/src/ui/timeline/ThinkingBlock.tsx +100 -0
- package/src/ui/timeline/Timeline.tsx +1410 -0
- package/src/ui/timeline/TranscriptShell.tsx +302 -0
- package/src/ui/timeline/TurnGroup.tsx +673 -0
- package/src/ui/timeline/layoutListWindow.ts +145 -0
- package/src/ui/timeline/liveViewportWindow.ts +68 -0
- package/src/ui/timeline/progressEntries.ts +156 -0
- package/src/ui/timeline/runActivityView.ts +37 -0
- package/src/ui/timeline/staticTranscriptCache.ts +174 -0
- package/src/ui/timeline/streamCoalesce.ts +53 -0
- package/src/ui/timeline/timelineMeasure.ts +3273 -0
- 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
|
+
}
|