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