@golba98/codexa 1.0.2 → 1.0.4
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 +396 -100
- package/bin/codexa.js +62 -144
- package/package.json +14 -8
- package/src/app.tsx +596 -306
- package/src/commands/handler.ts +6 -6
- package/src/config/buildInfo.ts +2 -2
- package/src/config/layeredConfig.ts +1 -1
- package/src/config/persistence.ts +10 -0
- package/src/config/runtimeConfig.ts +1 -1
- package/src/config/settings.ts +8 -16
- package/src/config/trustStore.ts +1 -1
- package/src/config/updateCheckCache.ts +19 -1
- package/src/core/README.md +52 -0
- package/src/core/agent/loop.ts +282 -0
- package/src/core/agent/protocol.ts +211 -0
- package/src/core/agent/tools.ts +414 -0
- package/src/core/{codexExecArgs.ts → codex/codexExecArgs.ts} +2 -2
- package/src/core/{codexLaunch.ts → codex/codexLaunch.ts} +3 -3
- package/src/core/{codexPrompt.ts → codex/codexPrompt.ts} +3 -3
- package/src/core/debug/modelStateDebug.ts +34 -0
- package/src/core/executables/antigravityExecutable.ts +48 -0
- package/src/core/executables/codexExecutable.ts +1 -0
- package/src/core/executables/executableResolver.ts +65 -43
- package/src/core/perf/renderDebug.ts +10 -6
- package/src/core/process/processValidation.ts +9 -5
- package/src/core/providerLauncher/launcher.ts +59 -42
- package/src/core/providerLauncher/registry.ts +30 -14
- package/src/core/providerLauncher/types.ts +11 -9
- package/src/core/providerLauncher/workspaceConfig.ts +41 -26
- package/src/core/providerRuntime/anthropic.ts +7 -1
- package/src/core/providerRuntime/antigravity.ts +305 -0
- package/src/core/providerRuntime/claudeCodeDiscovery.ts +268 -22
- package/src/core/providerRuntime/claudeCodeDiscoveryDebug.ts +55 -0
- package/src/core/providerRuntime/contextMetadata.ts +12 -24
- package/src/core/providerRuntime/local.ts +129 -51
- package/src/core/providerRuntime/models.ts +22 -11
- package/src/core/providerRuntime/registry.ts +58 -31
- package/src/core/providerRuntime/types.ts +19 -14
- package/src/core/providers/codexSubprocess.ts +2 -2
- package/src/core/providers/types.ts +1 -1
- package/src/core/{attachments.ts → shared/attachments.ts} +27 -4
- package/src/core/{cleanupFastFail.ts → shared/cleanupFastFail.ts} +1 -1
- package/src/core/{hollowResponseFormat.ts → shared/hollowResponseFormat.ts} +1 -1
- package/src/core/terminal/clearFrameBoundary.ts +814 -0
- package/src/core/terminal/frameLock.ts +109 -0
- package/src/core/terminal/inkRenderReset.ts +123 -0
- package/src/core/terminal/terminalControl.ts +22 -0
- package/src/core/terminal/terminalTitle.ts +16 -102
- package/src/core/version/channel.ts +23 -0
- package/src/core/version/updateCheck.ts +193 -0
- package/src/core/{launchContext.ts → workspace/launchContext.ts} +32 -39
- package/src/core/{planStorage.ts → workspace/planStorage.ts} +2 -2
- package/src/core/{workspaceGuard.ts → workspace/workspaceGuard.ts} +97 -13
- package/src/headless/execRunner.ts +2 -2
- package/src/index.tsx +43 -89
- package/src/session/appSession.ts +10 -7
- package/src/session/chatLifecycle.ts +1 -1
- package/src/session/liveRenderScheduler.ts +1 -1
- package/src/session/types.ts +3 -2
- package/src/ui/ActionRequiredBlock.tsx +5 -5
- package/src/ui/ActivityBars.tsx +3 -3
- package/src/ui/ActivityIndicator.tsx +6 -6
- package/src/ui/AgentBlock.tsx +6 -6
- package/src/ui/AnimatedStatusText.tsx +1 -1
- package/src/ui/AppShell.tsx +670 -719
- package/src/ui/AttachmentImportPanel.tsx +8 -8
- package/src/ui/AuthPanel.tsx +20 -20
- package/src/ui/BottomComposer.tsx +158 -118
- package/src/ui/DashCard.tsx +3 -3
- package/src/ui/Markdown.tsx +17 -17
- package/src/ui/ModelPickerScreen.tsx +222 -42
- package/src/ui/ModelReasoningPicker.tsx +15 -15
- package/src/ui/Panel.tsx +3 -3
- package/src/ui/PlanActionPicker.tsx +6 -6
- package/src/ui/PlanReviewPanel.tsx +9 -9
- package/src/ui/ProviderPicker.tsx +735 -321
- package/src/ui/RunFooter.tsx +3 -3
- package/src/ui/RuntimeStatusBar.tsx +108 -0
- package/src/ui/SelectionPanel.tsx +8 -4
- package/src/ui/SettingsPanel.tsx +9 -9
- package/src/ui/Spinner.tsx +1 -1
- package/src/ui/TextEntryPanel.tsx +11 -11
- package/src/ui/ThinkingBlock.tsx +8 -8
- package/src/ui/Timeline.tsx +1625 -1472
- package/src/ui/TopHeader.tsx +437 -293
- package/src/ui/TranscriptShell.tsx +322 -0
- package/src/ui/TurnGroup.tsx +33 -33
- package/src/ui/UpdateAvailableCard.tsx +41 -0
- package/src/ui/UpdatePromptPanel.tsx +197 -0
- package/src/ui/focus.ts +3 -0
- package/src/ui/layout.ts +299 -25
- package/src/ui/layoutListWindow.ts +145 -0
- package/src/ui/logoVariants.ts +103 -0
- package/src/ui/modeDisplay.ts +12 -12
- package/src/ui/runtimeDisplay.ts +112 -0
- package/src/ui/textLayout.ts +15 -4
- package/src/ui/theme.tsx +274 -395
- package/src/ui/timelineMeasure.ts +218 -136
- package/scripts/audit-codexa-capabilities.mjs +0 -466
- package/scripts/gen-build-info.mjs +0 -33
- package/scripts/smoke-terminal-bench.mjs +0 -35
- package/src/appRenderStability.test.ts +0 -131
- package/src/commands/handler.test.ts +0 -655
- package/src/config/launchArgs.test.ts +0 -189
- package/src/config/layeredConfig.test.ts +0 -143
- package/src/config/persistence.test.ts +0 -114
- package/src/config/runtimeConfig.test.ts +0 -218
- package/src/config/settings.test.ts +0 -155
- package/src/config/trustStore.test.ts +0 -29
- package/src/core/attachments.test.ts +0 -155
- package/src/core/auth/codexAuth.test.ts +0 -68
- package/src/core/cleanupFastFail.test.ts +0 -76
- package/src/core/codex.ts +0 -124
- package/src/core/codexExecArgs.test.ts +0 -195
- package/src/core/codexLaunch.test.ts +0 -205
- package/src/core/codexPrompt.test.ts +0 -252
- package/src/core/executables/codexExecutable.test.ts +0 -212
- package/src/core/executables/executableResolver.test.ts +0 -129
- package/src/core/executables/geminiExecutable.test.ts +0 -116
- package/src/core/executables/pathSanityScan.test.ts +0 -47
- package/src/core/githubDiagnostics.test.ts +0 -92
- package/src/core/hollowResponseFormat.test.ts +0 -58
- package/src/core/launchContext.test.ts +0 -157
- package/src/core/models/codexCapabilities.test.ts +0 -45
- package/src/core/models/codexModelCapabilities.test.ts +0 -246
- package/src/core/models/modelSpecs.test.ts +0 -283
- package/src/core/perf/renderDebug.test.ts +0 -230
- package/src/core/planStorage.test.ts +0 -143
- package/src/core/process/CommandRunner.test.ts +0 -105
- package/src/core/projectInstructions.test.ts +0 -50
- package/src/core/providerLauncher/launcher.test.ts +0 -238
- package/src/core/providerLauncher/registry.test.ts +0 -324
- package/src/core/providerLauncher/workspaceConfig.test.ts +0 -638
- package/src/core/providerRuntime/anthropic.test.ts +0 -1120
- package/src/core/providerRuntime/capabilityProfile.test.ts +0 -311
- package/src/core/providerRuntime/contextMetadata.test.ts +0 -468
- package/src/core/providerRuntime/gemini.test.ts +0 -437
- package/src/core/providerRuntime/lmstudio.test.ts +0 -168
- package/src/core/providerRuntime/local.test.ts +0 -787
- package/src/core/providerRuntime/registry.test.ts +0 -233
- package/src/core/providers/codexJsonStream.test.ts +0 -148
- package/src/core/providers/codexSubprocess.test.ts +0 -68
- package/src/core/providers/codexTranscript.test.ts +0 -284
- package/src/core/terminal/startupClear.test.ts +0 -55
- package/src/core/terminal/terminalCapabilities.test.ts +0 -93
- package/src/core/terminal/terminalControl.test.ts +0 -75
- package/src/core/terminal/terminalSanitize.test.ts +0 -22
- package/src/core/terminal/terminalSelection.test.ts +0 -42
- package/src/core/terminal/terminalTitle.test.ts +0 -328
- package/src/core/updateCheck.test.ts +0 -194
- package/src/core/updateCheck.ts +0 -172
- package/src/core/workspaceActivity.test.ts +0 -163
- package/src/core/workspaceGuard.test.ts +0 -151
- package/src/core/workspaceRoot.test.ts +0 -23
- package/src/exec.test.ts +0 -13
- package/src/headless/execArgs.test.ts +0 -147
- package/src/headless/execRunner.test.ts +0 -436
- package/src/index.test.tsx +0 -620
- package/src/session/appSession.test.ts +0 -897
- package/src/session/chatLifecycle.test.ts +0 -64
- package/src/session/liveRenderScheduler.test.ts +0 -201
- package/src/session/planFlow.test.ts +0 -103
- package/src/session/planTranscript.test.ts +0 -65
- package/src/session/promptRunSchedule.test.ts +0 -36
- package/src/ui/ActivityIndicator.test.tsx +0 -58
- package/src/ui/AgentBlock.test.ts +0 -6
- package/src/ui/AnimatedStatusText.test.ts +0 -16
- package/src/ui/AppShell.test.tsx +0 -1776
- package/src/ui/AttachmentImportPanel.test.tsx +0 -204
- package/src/ui/BottomComposer.test.ts +0 -674
- package/src/ui/CodexLogo.tsx +0 -55
- package/src/ui/Markdown.test.ts +0 -157
- package/src/ui/ModelPickerProviderScope.test.tsx +0 -411
- package/src/ui/ModelPickerScreen.test.tsx +0 -99
- package/src/ui/ModelPickerState.test.tsx +0 -151
- package/src/ui/ModelReasoningPicker.test.tsx +0 -447
- package/src/ui/PlanReviewPanel.test.tsx +0 -267
- package/src/ui/PromptCardBorder.test.tsx +0 -161
- package/src/ui/ProviderPicker.test.tsx +0 -289
- package/src/ui/ProviderShortcut.test.tsx +0 -143
- package/src/ui/SettingsPanel.test.tsx +0 -233
- package/src/ui/StaticTranscriptItem.tsx +0 -56
- package/src/ui/Timeline.test.ts +0 -2067
- package/src/ui/TimelineNavigation.test.tsx +0 -201
- package/src/ui/TopHeader.test.tsx +0 -254
- package/src/ui/TurnGroup.test.tsx +0 -365
- package/src/ui/busyStatusAnimation.test.ts +0 -30
- package/src/ui/commandNormalize.test.ts +0 -142
- package/src/ui/diffRenderer.test.ts +0 -102
- package/src/ui/focusFlow.test.tsx +0 -1098
- package/src/ui/inputBuffer.test.ts +0 -151
- package/src/ui/layout.test.ts +0 -146
- package/src/ui/modeDisplay.test.ts +0 -42
- package/src/ui/runActivityView.test.ts +0 -89
- package/src/ui/runLifecycleView.test.tsx +0 -237
- package/src/ui/statusRenderIsolation.test.tsx +0 -654
- package/src/ui/terminalAnswerFormat.test.ts +0 -19
- package/src/ui/textLayout.test.ts +0 -18
- package/src/ui/themeFlow.test.ts +0 -53
- package/src/ui/timelineMeasureCache.test.ts +0 -986
- /package/src/core/{inputDebug.ts → debug/inputDebug.ts} +0 -0
- /package/src/core/{clipboard.ts → shared/clipboard.ts} +0 -0
- /package/src/core/{githubDiagnostics.ts → shared/githubDiagnostics.ts} +0 -0
- /package/src/core/{projectInstructions.ts → workspace/projectInstructions.ts} +0 -0
- /package/src/core/{workspaceActivity.ts → workspace/workspaceActivity.ts} +0 -0
- /package/src/core/{workspaceRoot.ts → workspace/workspaceRoot.ts} +0 -0
package/src/ui/Timeline.test.ts
DELETED
|
@@ -1,2067 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import test from "node:test";
|
|
3
|
-
import type { RunProgressEntry, TimelineEvent } from "../session/types.js";
|
|
4
|
-
import { TEST_RUNTIME } from "../test/runtimeTestUtils.js";
|
|
5
|
-
import { getShellWidth } from "./layout.js";
|
|
6
|
-
import { buildStaticIntroRows } from "./StaticIntroItem.js";
|
|
7
|
-
import type { TimelineRow, TimelineSnapshot } from "./timelineMeasure.js";
|
|
8
|
-
import { buildTimelineSnapshot } from "./timelineMeasure.js";
|
|
9
|
-
import {
|
|
10
|
-
buildActiveRenderItems,
|
|
11
|
-
buildStaticRenderItems,
|
|
12
|
-
buildTimelineItems,
|
|
13
|
-
createFinalizeContinuityViewport,
|
|
14
|
-
createFollowTailViewport,
|
|
15
|
-
endTimelineViewport,
|
|
16
|
-
findAnchorItem,
|
|
17
|
-
homeTimelineViewport,
|
|
18
|
-
isNearBottom,
|
|
19
|
-
pageDownTimelineViewport,
|
|
20
|
-
pageUpTimelineViewport,
|
|
21
|
-
parseTimelineNavigationInput,
|
|
22
|
-
parseWheelScrollDirections,
|
|
23
|
-
reflowTimelineViewport,
|
|
24
|
-
resolveTurnOpacity,
|
|
25
|
-
scrollTimelineViewport,
|
|
26
|
-
selectTimelineRows,
|
|
27
|
-
stepDownTimelineViewport,
|
|
28
|
-
stepUpTimelineViewport,
|
|
29
|
-
syncTimelineViewport,
|
|
30
|
-
type RenderTimelineItem,
|
|
31
|
-
type TimelineViewportState,
|
|
32
|
-
} from "./Timeline.js";
|
|
33
|
-
|
|
34
|
-
function createRow(key: string): TimelineRow {
|
|
35
|
-
return {
|
|
36
|
-
key,
|
|
37
|
-
spans: [{ text: key }],
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function createSnapshot(rowCounts: number[]): TimelineSnapshot {
|
|
42
|
-
const items = rowCounts.map((count, itemIndex) => {
|
|
43
|
-
const rows = Array.from({ length: count }, (_, rowIndex) => createRow(`item-${itemIndex}-row-${rowIndex}`));
|
|
44
|
-
return {
|
|
45
|
-
key: `item-${itemIndex}`,
|
|
46
|
-
rows,
|
|
47
|
-
rowCount: rows.length,
|
|
48
|
-
};
|
|
49
|
-
});
|
|
50
|
-
const rows = items.flatMap((item) => item.rows);
|
|
51
|
-
return {
|
|
52
|
-
items,
|
|
53
|
-
rows,
|
|
54
|
-
totalRows: rows.length,
|
|
55
|
-
itemCount: items.length,
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function createProgressEntry(sequence: number, text: string, blockTexts: string[] = [text]): RunProgressEntry {
|
|
60
|
-
return {
|
|
61
|
-
id: `progress-${sequence}`,
|
|
62
|
-
source: "reasoning",
|
|
63
|
-
text,
|
|
64
|
-
sequence,
|
|
65
|
-
createdAt: sequence,
|
|
66
|
-
updatedAt: sequence,
|
|
67
|
-
pendingNewlineCount: 0,
|
|
68
|
-
blocks: blockTexts.map((blockText, index) => ({
|
|
69
|
-
id: `progress-${sequence}-block-${index + 1}`,
|
|
70
|
-
text: blockText,
|
|
71
|
-
sequence: index + 1,
|
|
72
|
-
createdAt: sequence,
|
|
73
|
-
updatedAt: sequence,
|
|
74
|
-
status: index === blockTexts.length - 1 ? "active" : "completed",
|
|
75
|
-
})),
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function createCompletedProgressEntry(sequence: number, text: string, blockTexts: string[] = [text]): RunProgressEntry {
|
|
80
|
-
const entry = createProgressEntry(sequence, text, blockTexts);
|
|
81
|
-
return {
|
|
82
|
-
...entry,
|
|
83
|
-
blocks: entry.blocks.map((block) => ({
|
|
84
|
-
...block,
|
|
85
|
-
status: "completed",
|
|
86
|
-
})),
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
test("groups user, run, and assistant events into a single turn item", () => {
|
|
91
|
-
const events: TimelineEvent[] = [
|
|
92
|
-
{
|
|
93
|
-
id: 1,
|
|
94
|
-
type: "user",
|
|
95
|
-
createdAt: 1,
|
|
96
|
-
prompt: "Implement rate limiting",
|
|
97
|
-
turnId: 10,
|
|
98
|
-
},
|
|
99
|
-
{
|
|
100
|
-
id: 2,
|
|
101
|
-
type: "run",
|
|
102
|
-
createdAt: 2,
|
|
103
|
-
startedAt: 2,
|
|
104
|
-
durationMs: null,
|
|
105
|
-
backendId: "codex-subprocess",
|
|
106
|
-
backendLabel: "Codexa",
|
|
107
|
-
runtime: TEST_RUNTIME,
|
|
108
|
-
prompt: "Implement rate limiting",
|
|
109
|
-
progressEntries: [createProgressEntry(1, "Scanning routes...")],
|
|
110
|
-
status: "running",
|
|
111
|
-
summary: "Running",
|
|
112
|
-
truncatedOutput: false,
|
|
113
|
-
toolActivities: [],
|
|
114
|
-
activity: [],
|
|
115
|
-
touchedFileCount: 0,
|
|
116
|
-
errorMessage: null,
|
|
117
|
-
turnId: 10,
|
|
118
|
-
},
|
|
119
|
-
{
|
|
120
|
-
id: 3,
|
|
121
|
-
type: "assistant",
|
|
122
|
-
createdAt: 3,
|
|
123
|
-
content: "I found the auth router.",
|
|
124
|
-
contentChunks: [],
|
|
125
|
-
turnId: 10,
|
|
126
|
-
},
|
|
127
|
-
{
|
|
128
|
-
id: 4,
|
|
129
|
-
type: "system",
|
|
130
|
-
createdAt: 4,
|
|
131
|
-
title: "Mode updated",
|
|
132
|
-
content: "AUTO-EDIT enabled",
|
|
133
|
-
},
|
|
134
|
-
];
|
|
135
|
-
|
|
136
|
-
const items = buildTimelineItems(events);
|
|
137
|
-
assert.equal(items.length, 2);
|
|
138
|
-
assert.equal(items[0]?.type, "turn");
|
|
139
|
-
assert.equal(items[1]?.type, "event");
|
|
140
|
-
|
|
141
|
-
if (items[0]?.type !== "turn") {
|
|
142
|
-
throw new Error("Expected first item to be a turn");
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
assert.equal(items[0].turnId, 10);
|
|
146
|
-
assert.equal(items[0].user?.prompt, "Implement rate limiting");
|
|
147
|
-
assert.equal(items[0].run?.progressEntries[0]?.text, "Scanning routes...");
|
|
148
|
-
assert.equal(items[0].assistant?.content, "I found the auth router.");
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test("empty transcript event state builds no timeline render rows", () => {
|
|
152
|
-
const items = buildTimelineItems([]);
|
|
153
|
-
const renderItems = buildStaticRenderItems(items, [], null, null, null);
|
|
154
|
-
const snapshot = buildTimelineSnapshot(renderItems, { totalWidth: 80 });
|
|
155
|
-
|
|
156
|
-
assert.deepEqual(items, []);
|
|
157
|
-
assert.deepEqual(renderItems, []);
|
|
158
|
-
assert.equal(snapshot.totalRows, 0);
|
|
159
|
-
assert.equal(snapshot.itemCount, 0);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test("derives active, recent, and dim turn opacity from ordered turn ids", () => {
|
|
163
|
-
const turnIds = [1, 2, 3];
|
|
164
|
-
|
|
165
|
-
assert.equal(resolveTurnOpacity(turnIds, 3, 3), "active");
|
|
166
|
-
assert.equal(resolveTurnOpacity(turnIds, 2, 3), "recent");
|
|
167
|
-
assert.equal(resolveTurnOpacity(turnIds, 1, 3), "dim");
|
|
168
|
-
|
|
169
|
-
assert.equal(resolveTurnOpacity(turnIds, 3, null), "recent");
|
|
170
|
-
assert.equal(resolveTurnOpacity(turnIds, 1, null), "dim");
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
test("separates committed and active turn render state", () => {
|
|
174
|
-
const committed = buildTimelineItems([
|
|
175
|
-
{
|
|
176
|
-
id: 1,
|
|
177
|
-
type: "user",
|
|
178
|
-
createdAt: 1,
|
|
179
|
-
prompt: "Completed turn",
|
|
180
|
-
turnId: 1,
|
|
181
|
-
},
|
|
182
|
-
{
|
|
183
|
-
id: 2,
|
|
184
|
-
type: "run",
|
|
185
|
-
createdAt: 2,
|
|
186
|
-
startedAt: 2,
|
|
187
|
-
durationMs: 250,
|
|
188
|
-
backendId: "codex-subprocess",
|
|
189
|
-
backendLabel: "Codexa",
|
|
190
|
-
runtime: TEST_RUNTIME,
|
|
191
|
-
prompt: "Completed turn",
|
|
192
|
-
progressEntries: [],
|
|
193
|
-
status: "completed",
|
|
194
|
-
summary: "Completed",
|
|
195
|
-
truncatedOutput: false,
|
|
196
|
-
toolActivities: [],
|
|
197
|
-
activity: [],
|
|
198
|
-
touchedFileCount: 0,
|
|
199
|
-
errorMessage: null,
|
|
200
|
-
turnId: 1,
|
|
201
|
-
},
|
|
202
|
-
{
|
|
203
|
-
id: 3,
|
|
204
|
-
type: "assistant",
|
|
205
|
-
createdAt: 3,
|
|
206
|
-
content: "Done",
|
|
207
|
-
contentChunks: [],
|
|
208
|
-
turnId: 1,
|
|
209
|
-
},
|
|
210
|
-
]);
|
|
211
|
-
|
|
212
|
-
const active = buildTimelineItems([
|
|
213
|
-
{
|
|
214
|
-
id: 4,
|
|
215
|
-
type: "user",
|
|
216
|
-
createdAt: 4,
|
|
217
|
-
prompt: "Live turn",
|
|
218
|
-
turnId: 2,
|
|
219
|
-
},
|
|
220
|
-
{
|
|
221
|
-
id: 5,
|
|
222
|
-
type: "run",
|
|
223
|
-
createdAt: 5,
|
|
224
|
-
startedAt: 5,
|
|
225
|
-
durationMs: null,
|
|
226
|
-
backendId: "codex-subprocess",
|
|
227
|
-
backendLabel: "Codexa",
|
|
228
|
-
runtime: TEST_RUNTIME,
|
|
229
|
-
prompt: "Live turn",
|
|
230
|
-
progressEntries: [],
|
|
231
|
-
status: "running",
|
|
232
|
-
summary: "Running",
|
|
233
|
-
truncatedOutput: false,
|
|
234
|
-
toolActivities: [],
|
|
235
|
-
activity: [],
|
|
236
|
-
touchedFileCount: 0,
|
|
237
|
-
errorMessage: null,
|
|
238
|
-
turnId: 2,
|
|
239
|
-
},
|
|
240
|
-
]);
|
|
241
|
-
|
|
242
|
-
const turnIds = [1, 2];
|
|
243
|
-
const staticItems = buildStaticRenderItems(committed, turnIds, 2, null, null);
|
|
244
|
-
const activeThinkingItems = buildActiveRenderItems(active, turnIds, { kind: "THINKING", turnId: 2 });
|
|
245
|
-
const activeStreamingItems = buildActiveRenderItems(active, turnIds, { kind: "RESPONDING", turnId: 2 });
|
|
246
|
-
|
|
247
|
-
assert.equal(staticItems[0]?.type, "turn");
|
|
248
|
-
assert.equal(staticItems[0]?.type === "turn" ? staticItems[0].renderState.runPhase : "none", "final");
|
|
249
|
-
assert.equal(staticItems[0]?.type === "turn" ? staticItems[0].renderState.opacity : "dim", "recent");
|
|
250
|
-
assert.equal(activeThinkingItems[0]?.type === "turn" ? activeThinkingItems[0].renderState.runPhase : "none", "thinking");
|
|
251
|
-
assert.equal(activeStreamingItems[0]?.type === "turn" ? activeStreamingItems[0].renderState.runPhase : "none", "streaming");
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
test("builds multi-row snapshots from wrapped timeline items", () => {
|
|
255
|
-
const item: RenderTimelineItem = {
|
|
256
|
-
key: "event-1",
|
|
257
|
-
type: "event",
|
|
258
|
-
padded: false,
|
|
259
|
-
event: {
|
|
260
|
-
id: 1,
|
|
261
|
-
type: "system",
|
|
262
|
-
createdAt: 1,
|
|
263
|
-
title: "Long system event",
|
|
264
|
-
content: "This content is intentionally long enough to wrap across multiple transcript rows in a narrow viewport.",
|
|
265
|
-
},
|
|
266
|
-
};
|
|
267
|
-
|
|
268
|
-
const snapshot = buildTimelineSnapshot([item], { totalWidth: 32 });
|
|
269
|
-
assert(snapshot.totalRows > 3);
|
|
270
|
-
assert.equal(snapshot.itemCount, 1);
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
test("Codexa intro renders as a normal timeline item", () => {
|
|
274
|
-
const item: RenderTimelineItem = {
|
|
275
|
-
key: "codexa-intro",
|
|
276
|
-
type: "intro",
|
|
277
|
-
padded: true,
|
|
278
|
-
intro: {
|
|
279
|
-
version: "1.0.1",
|
|
280
|
-
layoutMode: "full",
|
|
281
|
-
authLabel: "Authenticated",
|
|
282
|
-
workspaceLabel: "C:\\Development\\1-JavaScript\\13-Custom-CLI-Normal",
|
|
283
|
-
},
|
|
284
|
-
};
|
|
285
|
-
|
|
286
|
-
const snapshot = buildTimelineSnapshot([item], { totalWidth: 110 });
|
|
287
|
-
const lines = snapshot.rows.map((row) => row.spans.map((span) => span.text).join(""));
|
|
288
|
-
const text = lines.join("\n");
|
|
289
|
-
const versionLineIndex = lines.findIndex((line) => line.includes("Codexa v1.0.1"));
|
|
290
|
-
|
|
291
|
-
assert.equal(snapshot.itemCount, 1);
|
|
292
|
-
assert.match(text, /██████/);
|
|
293
|
-
assert.match(text, /Codexa v1\.0\.1/);
|
|
294
|
-
assert.match(text, /Auth: Authenticated/);
|
|
295
|
-
assert.match(text, /Workspace: 13-Custom-CLI-Normal/);
|
|
296
|
-
assert.doesNotMatch(text, /Model:/);
|
|
297
|
-
assert(versionLineIndex >= 0 && versionLineIndex < 6);
|
|
298
|
-
assert.match(lines[versionLineIndex]!, /[█╔║╝]/);
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
test("static intro uses the padded timeline snapshot path", () => {
|
|
302
|
-
const layout = {
|
|
303
|
-
cols: 120,
|
|
304
|
-
rows: 40,
|
|
305
|
-
mode: "full" as const,
|
|
306
|
-
};
|
|
307
|
-
const rows = buildStaticIntroRows({
|
|
308
|
-
authState: "authenticated",
|
|
309
|
-
workspaceLabel: "C:\\Development\\1-JavaScript\\13-Custom-CLI-Normal",
|
|
310
|
-
layout,
|
|
311
|
-
verboseMode: false,
|
|
312
|
-
workspaceRoot: null,
|
|
313
|
-
});
|
|
314
|
-
const shellWidth = getShellWidth(layout.cols);
|
|
315
|
-
const lines = rows.map((row) => row.spans.map((span) => span.text).join(""));
|
|
316
|
-
const text = lines.join("\n");
|
|
317
|
-
|
|
318
|
-
assert(rows.length >= 7);
|
|
319
|
-
assert(rows[0]!.key.startsWith("codexa-intro-wrapped-"));
|
|
320
|
-
assert(lines.every((line) => line.length === shellWidth));
|
|
321
|
-
assert(lines[0]!.startsWith(" "));
|
|
322
|
-
assert.match(text, /██████/);
|
|
323
|
-
assert.match(text, /╚██████╗/);
|
|
324
|
-
assert.match(text, /Codexa v/);
|
|
325
|
-
assert.match(text, /Auth: Authenticated/);
|
|
326
|
-
assert.match(text, /Workspace: 13-Custom-CLI-Normal/);
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
test("Codexa intro scrolls out of the visible timeline window", () => {
|
|
330
|
-
const intro: RenderTimelineItem = {
|
|
331
|
-
key: "codexa-intro",
|
|
332
|
-
type: "intro",
|
|
333
|
-
padded: true,
|
|
334
|
-
intro: {
|
|
335
|
-
version: "1.0.1",
|
|
336
|
-
layoutMode: "full",
|
|
337
|
-
authLabel: "Authenticated",
|
|
338
|
-
workspaceLabel: "workspace",
|
|
339
|
-
},
|
|
340
|
-
};
|
|
341
|
-
const rows = Array.from({ length: 30 }, (_, index) => ({
|
|
342
|
-
key: `event-${index}`,
|
|
343
|
-
type: "event" as const,
|
|
344
|
-
padded: false,
|
|
345
|
-
event: {
|
|
346
|
-
id: index + 1,
|
|
347
|
-
type: "system" as const,
|
|
348
|
-
createdAt: index + 1,
|
|
349
|
-
title: `Event ${index}`,
|
|
350
|
-
content: "Transcript row",
|
|
351
|
-
},
|
|
352
|
-
}));
|
|
353
|
-
|
|
354
|
-
const snapshot = buildTimelineSnapshot([intro, ...rows], { totalWidth: 80 });
|
|
355
|
-
const selection = selectTimelineRows(snapshot, createFollowTailViewport(snapshot.totalRows), 8);
|
|
356
|
-
const text = selection.visibleRows.map((row) => row.spans.map((span) => span.text).join("")).join("\n");
|
|
357
|
-
|
|
358
|
-
assert.doesNotMatch(text, /██████/);
|
|
359
|
-
assert.match(text, /Event 29/);
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
test("timeline snapshot keeps the prompt card top border closed", () => {
|
|
363
|
-
const items = buildTimelineItems([
|
|
364
|
-
{
|
|
365
|
-
id: 1,
|
|
366
|
-
type: "user",
|
|
367
|
-
createdAt: 1,
|
|
368
|
-
prompt: "Reproduce the prompt border issue",
|
|
369
|
-
turnId: 10,
|
|
370
|
-
},
|
|
371
|
-
{
|
|
372
|
-
id: 2,
|
|
373
|
-
type: "run",
|
|
374
|
-
createdAt: 2,
|
|
375
|
-
startedAt: 2,
|
|
376
|
-
durationMs: null,
|
|
377
|
-
backendId: "codex-subprocess",
|
|
378
|
-
backendLabel: "Codexa",
|
|
379
|
-
runtime: TEST_RUNTIME,
|
|
380
|
-
prompt: "Reproduce the prompt border issue",
|
|
381
|
-
progressEntries: [],
|
|
382
|
-
status: "running",
|
|
383
|
-
summary: "Running",
|
|
384
|
-
truncatedOutput: false,
|
|
385
|
-
toolActivities: [],
|
|
386
|
-
activity: [],
|
|
387
|
-
touchedFileCount: 0,
|
|
388
|
-
errorMessage: null,
|
|
389
|
-
turnId: 10,
|
|
390
|
-
},
|
|
391
|
-
]);
|
|
392
|
-
const renderItems = buildActiveRenderItems(items, [10], { kind: "THINKING", turnId: 10 });
|
|
393
|
-
const snapshot = buildTimelineSnapshot(renderItems, { totalWidth: 56 });
|
|
394
|
-
const topBorder = snapshot.rows[0]?.spans.map((span) => span.text).join("").trim();
|
|
395
|
-
|
|
396
|
-
assert.equal(topBorder?.includes("╭── PROMPT"), true);
|
|
397
|
-
assert.match(topBorder ?? "", /──╮$/);
|
|
398
|
-
assert.doesNotMatch(topBorder ?? "", / ──╮$/);
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
test("first active run fallback immediately shows Codex thinking status", () => {
|
|
402
|
-
const items = buildTimelineItems([
|
|
403
|
-
{
|
|
404
|
-
id: 1,
|
|
405
|
-
type: "user",
|
|
406
|
-
createdAt: 1,
|
|
407
|
-
prompt: "Inspect the project",
|
|
408
|
-
turnId: 11,
|
|
409
|
-
},
|
|
410
|
-
{
|
|
411
|
-
id: 2,
|
|
412
|
-
type: "run",
|
|
413
|
-
createdAt: 2,
|
|
414
|
-
startedAt: 2,
|
|
415
|
-
durationMs: null,
|
|
416
|
-
backendId: "codex-subprocess",
|
|
417
|
-
backendLabel: "Codexa",
|
|
418
|
-
runtime: TEST_RUNTIME,
|
|
419
|
-
prompt: "Inspect the project",
|
|
420
|
-
progressEntries: [],
|
|
421
|
-
status: "running",
|
|
422
|
-
summary: "Codexa is thinking...",
|
|
423
|
-
truncatedOutput: false,
|
|
424
|
-
toolActivities: [],
|
|
425
|
-
activity: [],
|
|
426
|
-
touchedFileCount: 0,
|
|
427
|
-
errorMessage: null,
|
|
428
|
-
turnId: 11,
|
|
429
|
-
},
|
|
430
|
-
]);
|
|
431
|
-
const renderItems = buildActiveRenderItems(items, [11], { kind: "THINKING", turnId: 11 });
|
|
432
|
-
const snapshot = buildTimelineSnapshot(renderItems, { totalWidth: 56 });
|
|
433
|
-
const joined = snapshot.rows.map((row) => row.spans.map((span) => span.text).join("")).join("\n");
|
|
434
|
-
|
|
435
|
-
assert.doesNotMatch(joined, /Codex is working/);
|
|
436
|
-
assert.doesNotMatch(joined, /Running\.\.\./);
|
|
437
|
-
assert.doesNotMatch(joined, /Waiting for response/i);
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
test("keeps a frozen browse snapshot while live rows continue to arrive", () => {
|
|
441
|
-
const live = createSnapshot([2, 2]);
|
|
442
|
-
const browsing = pageUpTimelineViewport(createFollowTailViewport(live.totalRows), live, 3);
|
|
443
|
-
const updated = createSnapshot([2, 2, 1]);
|
|
444
|
-
const withUpdate = syncTimelineViewport(browsing, updated);
|
|
445
|
-
const selected = selectTimelineRows(updated, withUpdate, 3);
|
|
446
|
-
|
|
447
|
-
assert.equal(withUpdate.followTail, false);
|
|
448
|
-
assert.equal(withUpdate.unseenItems, 1);
|
|
449
|
-
assert.equal(withUpdate.unseenRows, 1);
|
|
450
|
-
assert.equal(selected.sourceSnapshot.itemCount, 2);
|
|
451
|
-
assert.deepEqual(selected.visibleRows.map((row) => row.key), [
|
|
452
|
-
"item-0-row-0",
|
|
453
|
-
"item-0-row-1",
|
|
454
|
-
"item-1-row-0",
|
|
455
|
-
]);
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
test("page down from the frozen tail resumes live follow mode", () => {
|
|
459
|
-
const live = createSnapshot([2, 2, 1]);
|
|
460
|
-
const frozenSnapshot = createSnapshot([2, 2]);
|
|
461
|
-
const frozenTail = {
|
|
462
|
-
anchorRow: frozenSnapshot.totalRows - 1,
|
|
463
|
-
followTail: false,
|
|
464
|
-
unseenItems: 1,
|
|
465
|
-
unseenRows: 1,
|
|
466
|
-
frozenSnapshot,
|
|
467
|
-
};
|
|
468
|
-
const resumed = pageDownTimelineViewport(frozenTail, live, 3);
|
|
469
|
-
|
|
470
|
-
assert.equal(resumed.followTail, true);
|
|
471
|
-
assert.equal(resumed.anchorRow, live.totalRows - 1);
|
|
472
|
-
assert.equal(resumed.frozenSnapshot, null);
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
test("wheel stepping leaves follow mode and only resumes at the frozen tail", () => {
|
|
476
|
-
const snapshot = createSnapshot([1, 1, 1, 1]);
|
|
477
|
-
const viewportRows = 3;
|
|
478
|
-
|
|
479
|
-
const stepUp = stepUpTimelineViewport(createFollowTailViewport(snapshot.totalRows), snapshot, viewportRows);
|
|
480
|
-
assert.equal(stepUp.followTail, false);
|
|
481
|
-
assert.equal(stepUp.anchorRow, snapshot.totalRows - 2);
|
|
482
|
-
assert.equal(stepUp.frozenSnapshot?.itemCount, 4);
|
|
483
|
-
|
|
484
|
-
const stepDown = stepDownTimelineViewport(stepUp, snapshot, viewportRows);
|
|
485
|
-
assert.equal(stepDown.followTail, true);
|
|
486
|
-
assert.equal(stepDown.anchorRow, snapshot.totalRows - 1);
|
|
487
|
-
assert.equal(stepDown.frozenSnapshot, null);
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
test("manual browse snapshot survives run start and first assistant delta", () => {
|
|
491
|
-
const initial = createSnapshot([1, 1, 1, 1]);
|
|
492
|
-
const browsing = stepUpTimelineViewport(createFollowTailViewport(initial.totalRows), initial, 3);
|
|
493
|
-
const afterRunStart = syncTimelineViewport(browsing, createSnapshot([1, 1, 1, 1, 1]));
|
|
494
|
-
const afterFirstDelta = syncTimelineViewport(afterRunStart, createSnapshot([1, 1, 1, 1, 1, 2]));
|
|
495
|
-
const selected = selectTimelineRows(createSnapshot([1, 1, 1, 1, 1, 2]), afterFirstDelta, 3);
|
|
496
|
-
|
|
497
|
-
assert.equal(afterRunStart.followTail, false);
|
|
498
|
-
assert.equal(afterRunStart.unseenItems, 1);
|
|
499
|
-
assert.equal(afterRunStart.unseenRows, 1);
|
|
500
|
-
assert.equal(afterFirstDelta.followTail, false);
|
|
501
|
-
assert.equal(afterFirstDelta.unseenItems, 2);
|
|
502
|
-
assert.equal(afterFirstDelta.unseenRows, 3);
|
|
503
|
-
assert.deepEqual(selected.visibleRows.map((row) => row.key), [
|
|
504
|
-
"item-0-row-0",
|
|
505
|
-
"item-1-row-0",
|
|
506
|
-
"item-2-row-0",
|
|
507
|
-
]);
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
test("default timeline omits active processing text while a run is streaming", () => {
|
|
511
|
-
const items = buildTimelineItems([
|
|
512
|
-
{
|
|
513
|
-
id: 1,
|
|
514
|
-
type: "user",
|
|
515
|
-
createdAt: 1,
|
|
516
|
-
prompt: "Create a file",
|
|
517
|
-
turnId: 99,
|
|
518
|
-
},
|
|
519
|
-
{
|
|
520
|
-
id: 2,
|
|
521
|
-
type: "run",
|
|
522
|
-
createdAt: 2,
|
|
523
|
-
startedAt: 2,
|
|
524
|
-
durationMs: null,
|
|
525
|
-
backendId: "codex-subprocess",
|
|
526
|
-
backendLabel: "Codexa",
|
|
527
|
-
runtime: TEST_RUNTIME,
|
|
528
|
-
prompt: "Create a file",
|
|
529
|
-
progressEntries: [
|
|
530
|
-
createProgressEntry(1, "Todo 1/2: Write Hello_World.py"),
|
|
531
|
-
createProgressEntry(2, "Verifying generated file"),
|
|
532
|
-
],
|
|
533
|
-
status: "running",
|
|
534
|
-
summary: "Running",
|
|
535
|
-
truncatedOutput: false,
|
|
536
|
-
toolActivities: [
|
|
537
|
-
{
|
|
538
|
-
id: "tool-1",
|
|
539
|
-
command: "python -m pytest",
|
|
540
|
-
status: "running",
|
|
541
|
-
startedAt: 2,
|
|
542
|
-
},
|
|
543
|
-
],
|
|
544
|
-
activity: [
|
|
545
|
-
{
|
|
546
|
-
path: "Hello_World.py",
|
|
547
|
-
operation: "created",
|
|
548
|
-
detectedAt: 3,
|
|
549
|
-
},
|
|
550
|
-
],
|
|
551
|
-
touchedFileCount: 1,
|
|
552
|
-
errorMessage: null,
|
|
553
|
-
turnId: 99,
|
|
554
|
-
},
|
|
555
|
-
{
|
|
556
|
-
id: 3,
|
|
557
|
-
type: "assistant",
|
|
558
|
-
createdAt: 4,
|
|
559
|
-
content: "I created the file and I am verifying it.",
|
|
560
|
-
contentChunks: [],
|
|
561
|
-
turnId: 99,
|
|
562
|
-
},
|
|
563
|
-
]);
|
|
564
|
-
|
|
565
|
-
const renderItems = buildActiveRenderItems(items, [99], { kind: "RESPONDING", turnId: 99 });
|
|
566
|
-
const snapshot = buildTimelineSnapshot(renderItems, { totalWidth: 70 });
|
|
567
|
-
const joined = snapshot.rows
|
|
568
|
-
.map((row) => row.spans.map((span) => span.text).join(""))
|
|
569
|
-
.join("\n");
|
|
570
|
-
|
|
571
|
-
assert.doesNotMatch(joined, /Codex/);
|
|
572
|
-
assert.doesNotMatch(joined, /Verifying generated file/);
|
|
573
|
-
assert.doesNotMatch(joined, /Todo 1\/2/);
|
|
574
|
-
assert.match(joined, /python -m pytest/);
|
|
575
|
-
assert.doesNotMatch(joined, /Hello_World\.py/);
|
|
576
|
-
assert.match(joined, /action/);
|
|
577
|
-
assert.doesNotMatch(joined, /^\s*thinking\b/m);
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
test("streaming omits active processing text while completed processing stays stable", () => {
|
|
581
|
-
const items = buildTimelineItems([
|
|
582
|
-
{
|
|
583
|
-
id: 1,
|
|
584
|
-
type: "user",
|
|
585
|
-
createdAt: 1,
|
|
586
|
-
prompt: "Improve streaming thoughts",
|
|
587
|
-
turnId: 100,
|
|
588
|
-
},
|
|
589
|
-
{
|
|
590
|
-
id: 2,
|
|
591
|
-
type: "run",
|
|
592
|
-
createdAt: 2,
|
|
593
|
-
startedAt: 2,
|
|
594
|
-
durationMs: null,
|
|
595
|
-
backendId: "codex-subprocess",
|
|
596
|
-
backendLabel: "Codexa",
|
|
597
|
-
runtime: TEST_RUNTIME,
|
|
598
|
-
prompt: "Improve streaming thoughts",
|
|
599
|
-
progressEntries: [
|
|
600
|
-
createCompletedProgressEntry(1, "I inspected the renderer and found the content is flattened into plain card rows.", [
|
|
601
|
-
"I inspected the renderer and found the content is flattened into plain card rows.",
|
|
602
|
-
]),
|
|
603
|
-
createProgressEntry(2, "Next I am separating completed thoughts from the active live segment.", [
|
|
604
|
-
"Next I am separating completed thoughts from the active live segment.",
|
|
605
|
-
]),
|
|
606
|
-
],
|
|
607
|
-
status: "running",
|
|
608
|
-
summary: "Running",
|
|
609
|
-
truncatedOutput: false,
|
|
610
|
-
toolActivities: [],
|
|
611
|
-
activity: [],
|
|
612
|
-
touchedFileCount: 0,
|
|
613
|
-
errorMessage: null,
|
|
614
|
-
turnId: 100,
|
|
615
|
-
},
|
|
616
|
-
{
|
|
617
|
-
id: 3,
|
|
618
|
-
type: "assistant",
|
|
619
|
-
createdAt: 4,
|
|
620
|
-
content: "Working...",
|
|
621
|
-
contentChunks: [],
|
|
622
|
-
turnId: 100,
|
|
623
|
-
},
|
|
624
|
-
]);
|
|
625
|
-
|
|
626
|
-
const renderItems = buildActiveRenderItems(items, [100], { kind: "RESPONDING", turnId: 100 });
|
|
627
|
-
const snapshot = buildTimelineSnapshot(renderItems, { totalWidth: 54 });
|
|
628
|
-
const joined = snapshot.rows
|
|
629
|
-
.map((row) => row.spans.map((span) => span.text).join(""))
|
|
630
|
-
.join("\n");
|
|
631
|
-
|
|
632
|
-
assert.match(joined, /I inspected the renderer/);
|
|
633
|
-
assert.doesNotMatch(joined, /Next I am separating/);
|
|
634
|
-
assert.match(joined, /Codex/);
|
|
635
|
-
assert.doesNotMatch(joined, /▌/);
|
|
636
|
-
assert.ok(snapshot.rows.every((row) => row.spans.map((span) => span.text).join("").length <= 54));
|
|
637
|
-
});
|
|
638
|
-
|
|
639
|
-
test("completed runs keep progress updates as separate readable blocks", () => {
|
|
640
|
-
const items = buildTimelineItems([
|
|
641
|
-
{
|
|
642
|
-
id: 1,
|
|
643
|
-
type: "user",
|
|
644
|
-
createdAt: 1,
|
|
645
|
-
prompt: "Investigate the failure",
|
|
646
|
-
turnId: 77,
|
|
647
|
-
},
|
|
648
|
-
{
|
|
649
|
-
id: 2,
|
|
650
|
-
type: "run",
|
|
651
|
-
createdAt: 2,
|
|
652
|
-
startedAt: 2,
|
|
653
|
-
durationMs: 1200,
|
|
654
|
-
backendId: "codex-subprocess",
|
|
655
|
-
backendLabel: "Codexa",
|
|
656
|
-
runtime: TEST_RUNTIME,
|
|
657
|
-
prompt: "Investigate the failure",
|
|
658
|
-
progressEntries: [
|
|
659
|
-
createProgressEntry(1, "Checking the failing test"),
|
|
660
|
-
createProgressEntry(2, "Reviewing output\n\nComparing expected behavior", [
|
|
661
|
-
"Reviewing output",
|
|
662
|
-
"Comparing expected behavior",
|
|
663
|
-
]),
|
|
664
|
-
],
|
|
665
|
-
status: "completed",
|
|
666
|
-
summary: "Completed",
|
|
667
|
-
truncatedOutput: false,
|
|
668
|
-
toolActivities: [],
|
|
669
|
-
activity: [],
|
|
670
|
-
touchedFileCount: 0,
|
|
671
|
-
errorMessage: null,
|
|
672
|
-
turnId: 77,
|
|
673
|
-
},
|
|
674
|
-
{
|
|
675
|
-
id: 3,
|
|
676
|
-
type: "assistant",
|
|
677
|
-
createdAt: 3,
|
|
678
|
-
content: "Done",
|
|
679
|
-
contentChunks: [],
|
|
680
|
-
turnId: 77,
|
|
681
|
-
},
|
|
682
|
-
]);
|
|
683
|
-
|
|
684
|
-
const renderItems = buildStaticRenderItems(items, [77], null, null, null);
|
|
685
|
-
const snapshot = buildTimelineSnapshot(renderItems, { totalWidth: 72 });
|
|
686
|
-
const joined = snapshot.rows
|
|
687
|
-
.map((row) => row.spans.map((span) => span.text).join(""))
|
|
688
|
-
.join("\n");
|
|
689
|
-
|
|
690
|
-
assert.match(joined, /Codex/);
|
|
691
|
-
assert.match(joined, /Checking the failing test/);
|
|
692
|
-
assert.match(joined, /Comparing expected behavior/);
|
|
693
|
-
assert.doesNotMatch(joined, /^\s*thinking\b/m);
|
|
694
|
-
});
|
|
695
|
-
|
|
696
|
-
test("assistant unified diffs render with semantic tones", () => {
|
|
697
|
-
const diff = [
|
|
698
|
-
"diff --git a/src/example.ts b/src/example.ts",
|
|
699
|
-
"index 1111111..2222222 100644",
|
|
700
|
-
"--- a/src/example.ts",
|
|
701
|
-
"+++ b/src/example.ts",
|
|
702
|
-
"@@ -1,3 +1,4 @@",
|
|
703
|
-
" const name = \"Codexa\";",
|
|
704
|
-
"-console.log(\"old\");",
|
|
705
|
-
"+console.log(\"new\");",
|
|
706
|
-
"+console.log(\"added\");",
|
|
707
|
-
" export default name;",
|
|
708
|
-
].join("\n");
|
|
709
|
-
const items = buildTimelineItems([
|
|
710
|
-
{
|
|
711
|
-
id: 1,
|
|
712
|
-
type: "user",
|
|
713
|
-
createdAt: 1,
|
|
714
|
-
prompt: "Show a diff",
|
|
715
|
-
turnId: 120,
|
|
716
|
-
},
|
|
717
|
-
{
|
|
718
|
-
id: 2,
|
|
719
|
-
type: "run",
|
|
720
|
-
createdAt: 2,
|
|
721
|
-
startedAt: 2,
|
|
722
|
-
durationMs: 100,
|
|
723
|
-
backendId: "codex-subprocess",
|
|
724
|
-
backendLabel: "Codexa",
|
|
725
|
-
runtime: TEST_RUNTIME,
|
|
726
|
-
prompt: "Show a diff",
|
|
727
|
-
progressEntries: [],
|
|
728
|
-
status: "completed",
|
|
729
|
-
summary: "Completed",
|
|
730
|
-
truncatedOutput: false,
|
|
731
|
-
toolActivities: [],
|
|
732
|
-
activity: [],
|
|
733
|
-
touchedFileCount: 0,
|
|
734
|
-
errorMessage: null,
|
|
735
|
-
turnId: 120,
|
|
736
|
-
},
|
|
737
|
-
{
|
|
738
|
-
id: 3,
|
|
739
|
-
type: "assistant",
|
|
740
|
-
createdAt: 3,
|
|
741
|
-
content: diff,
|
|
742
|
-
contentChunks: [],
|
|
743
|
-
turnId: 120,
|
|
744
|
-
},
|
|
745
|
-
]);
|
|
746
|
-
|
|
747
|
-
const renderItems = buildStaticRenderItems(items, [120], null, null, null);
|
|
748
|
-
const snapshot = buildTimelineSnapshot(renderItems, { totalWidth: 88 });
|
|
749
|
-
const spans = snapshot.rows.flatMap((row) => row.spans);
|
|
750
|
-
|
|
751
|
-
assert.equal(spans.find((span) => span.text.includes("diff --git"))?.tone, "info");
|
|
752
|
-
assert.equal(spans.find((span) => span.text.includes("@@ -1,3 +1,4 @@"))?.tone, "accent");
|
|
753
|
-
assert.equal(spans.find((span) => span.text.includes("-console.log(\"old\");"))?.tone, "error");
|
|
754
|
-
assert.equal(spans.find((span) => span.text.includes("+console.log(\"new\");"))?.tone, "success");
|
|
755
|
-
});
|
|
756
|
-
|
|
757
|
-
test("completed assistant turn renders local links as compact terminal paths", () => {
|
|
758
|
-
const content = [
|
|
759
|
-
"Purpose:",
|
|
760
|
-
"A small app.",
|
|
761
|
-
"",
|
|
762
|
-
"Main parts:",
|
|
763
|
-
"- [`src/App.tsx`](C:/Users/Example/Projects/Project/src/App.tsx#L22) Interactive UI",
|
|
764
|
-
"- [README.md](file:///C:/Users/Example/Projects/Project/README.md) Project overview",
|
|
765
|
-
"- C:\\Users\\Example\\Projects\\Project\\docs\\proof.md#L26 Formal proof",
|
|
766
|
-
"",
|
|
767
|
-
"External: [OpenAI](https://platform.openai.com/docs)",
|
|
768
|
-
].join("\n");
|
|
769
|
-
const items = buildTimelineItems([
|
|
770
|
-
{
|
|
771
|
-
id: 1,
|
|
772
|
-
type: "user",
|
|
773
|
-
createdAt: 1,
|
|
774
|
-
prompt: "What is this file?",
|
|
775
|
-
turnId: 121,
|
|
776
|
-
},
|
|
777
|
-
{
|
|
778
|
-
id: 2,
|
|
779
|
-
type: "run",
|
|
780
|
-
createdAt: 2,
|
|
781
|
-
startedAt: 2,
|
|
782
|
-
durationMs: 100,
|
|
783
|
-
backendId: "codex-subprocess",
|
|
784
|
-
backendLabel: "Codexa",
|
|
785
|
-
runtime: TEST_RUNTIME,
|
|
786
|
-
prompt: "What is this file?",
|
|
787
|
-
progressEntries: [],
|
|
788
|
-
status: "completed",
|
|
789
|
-
summary: "Completed",
|
|
790
|
-
truncatedOutput: false,
|
|
791
|
-
toolActivities: [],
|
|
792
|
-
activity: [],
|
|
793
|
-
touchedFileCount: 0,
|
|
794
|
-
errorMessage: null,
|
|
795
|
-
turnId: 121,
|
|
796
|
-
},
|
|
797
|
-
{
|
|
798
|
-
id: 3,
|
|
799
|
-
type: "assistant",
|
|
800
|
-
createdAt: 3,
|
|
801
|
-
content,
|
|
802
|
-
contentChunks: [],
|
|
803
|
-
turnId: 121,
|
|
804
|
-
},
|
|
805
|
-
]);
|
|
806
|
-
|
|
807
|
-
const renderItems = buildStaticRenderItems(items, [121], null, null, null);
|
|
808
|
-
const snapshot = buildTimelineSnapshot(renderItems, { totalWidth: 96 });
|
|
809
|
-
const joined = snapshot.rows.map((row) => row.spans.map((span) => span.text).join("")).join("\n");
|
|
810
|
-
|
|
811
|
-
assert.match(joined, /src\/App\.tsx:22/);
|
|
812
|
-
assert.match(joined, /README\.md/);
|
|
813
|
-
assert.match(joined, /docs\/proof\.md:26/);
|
|
814
|
-
assert.match(joined, /\[OpenAI\]\(https:\/\/platform\.openai\.com\/docs\)/);
|
|
815
|
-
assert.doesNotMatch(joined, /C:\/Users|C:\\Users|file:\/\//);
|
|
816
|
-
assert.doesNotMatch(joined, /\]\(C:/);
|
|
817
|
-
});
|
|
818
|
-
|
|
819
|
-
test("parses sgr mouse wheel directions without treating other mouse events as scroll", () => {
|
|
820
|
-
const raw = "\u001b[<64;12;9M\u001b[<65;12;10M\u001b[<0;12;10M";
|
|
821
|
-
assert.deepEqual(parseWheelScrollDirections(raw), ["up", "down"]);
|
|
822
|
-
});
|
|
823
|
-
|
|
824
|
-
test("parses timeline navigation keys from raw terminal input", () => {
|
|
825
|
-
assert.deepEqual(parseTimelineNavigationInput("\u001b[5~"), ["pageUp"]);
|
|
826
|
-
assert.deepEqual(parseTimelineNavigationInput("\u001b[6~"), ["pageDown"]);
|
|
827
|
-
assert.deepEqual(parseTimelineNavigationInput("\u001b[1;5H"), ["home"]);
|
|
828
|
-
assert.deepEqual(parseTimelineNavigationInput("\u001b[1;5F"), ["end"]);
|
|
829
|
-
assert.deepEqual(parseTimelineNavigationInput("\u001b[<64;12;9M\u001b[<65;12;10M"), ["wheelUp", "wheelDown"]);
|
|
830
|
-
});
|
|
831
|
-
|
|
832
|
-
test("home anchors the browse window to the first page and end restores tail follow", () => {
|
|
833
|
-
const snapshot = createSnapshot([2, 2, 2]);
|
|
834
|
-
const home = homeTimelineViewport(createFollowTailViewport(snapshot.totalRows), snapshot, 4);
|
|
835
|
-
const window = selectTimelineRows(snapshot, home, 4);
|
|
836
|
-
const end = endTimelineViewport(snapshot.totalRows);
|
|
837
|
-
|
|
838
|
-
assert.equal(home.followTail, false);
|
|
839
|
-
assert.equal(window.window.startRow, 0);
|
|
840
|
-
assert.equal(window.window.endRow, 4);
|
|
841
|
-
assert.equal(end.followTail, true);
|
|
842
|
-
assert.equal(end.anchorRow, snapshot.totalRows - 1);
|
|
843
|
-
});
|
|
844
|
-
|
|
845
|
-
// ─── findAnchorItem ───────────────────────────────────────────────────────────
|
|
846
|
-
|
|
847
|
-
test("findAnchorItem locates the item and row-within-item for a given anchorRow", () => {
|
|
848
|
-
// snapshot: 3 items with [2, 3, 2] rows = rows 0-1, 2-4, 5-6
|
|
849
|
-
const snapshot = createSnapshot([2, 3, 2]);
|
|
850
|
-
|
|
851
|
-
// First item, first row
|
|
852
|
-
assert.deepEqual(findAnchorItem(snapshot, 0), { itemIndex: 0, rowWithinItem: 0 });
|
|
853
|
-
// First item, last row
|
|
854
|
-
assert.deepEqual(findAnchorItem(snapshot, 1), { itemIndex: 0, rowWithinItem: 1 });
|
|
855
|
-
// Second item, first row
|
|
856
|
-
assert.deepEqual(findAnchorItem(snapshot, 2), { itemIndex: 1, rowWithinItem: 0 });
|
|
857
|
-
// Second item, middle row
|
|
858
|
-
assert.deepEqual(findAnchorItem(snapshot, 3), { itemIndex: 1, rowWithinItem: 1 });
|
|
859
|
-
// Second item, last row
|
|
860
|
-
assert.deepEqual(findAnchorItem(snapshot, 4), { itemIndex: 1, rowWithinItem: 2 });
|
|
861
|
-
// Third item, last row
|
|
862
|
-
assert.deepEqual(findAnchorItem(snapshot, 6), { itemIndex: 2, rowWithinItem: 1 });
|
|
863
|
-
});
|
|
864
|
-
|
|
865
|
-
test("findAnchorItem clamps to last item when anchorRow is beyond snapshot bounds", () => {
|
|
866
|
-
const snapshot = createSnapshot([2, 3]);
|
|
867
|
-
// totalRows = 5, so anchorRow 99 should clamp to last item's last row
|
|
868
|
-
const result = findAnchorItem(snapshot, 99);
|
|
869
|
-
assert.equal(result.itemIndex, 1);
|
|
870
|
-
assert.equal(result.rowWithinItem, 2); // last item has 3 rows → last = 2
|
|
871
|
-
});
|
|
872
|
-
|
|
873
|
-
// ─── reflowTimelineViewport ───────────────────────────────────────────────────
|
|
874
|
-
|
|
875
|
-
test("reflowTimelineViewport keeps followTail when user is pinned to bottom", () => {
|
|
876
|
-
const liveSnapshot = createSnapshot([2, 3, 2]);
|
|
877
|
-
const pinned = createFollowTailViewport(liveSnapshot.totalRows);
|
|
878
|
-
const reflowed = reflowTimelineViewport(pinned, liveSnapshot);
|
|
879
|
-
|
|
880
|
-
assert.equal(reflowed.followTail, true);
|
|
881
|
-
assert.equal(reflowed.anchorRow, liveSnapshot.totalRows - 1);
|
|
882
|
-
assert.equal(reflowed.frozenSnapshot, null);
|
|
883
|
-
});
|
|
884
|
-
|
|
885
|
-
test("reflowTimelineViewport maps anchor to same item when width decreases (more wrapping)", () => {
|
|
886
|
-
// Old layout: 2 items with [2, 3] rows. User is looking at row 1 (end of item-0).
|
|
887
|
-
const oldFrozen = createSnapshot([2, 3]);
|
|
888
|
-
const browsing: TimelineViewportState = {
|
|
889
|
-
anchorRow: 1, // last row of item-0
|
|
890
|
-
followTail: false,
|
|
891
|
-
unseenItems: 0,
|
|
892
|
-
unseenRows: 0,
|
|
893
|
-
frozenSnapshot: oldFrozen,
|
|
894
|
-
};
|
|
895
|
-
|
|
896
|
-
// New layout (narrower): same 2 items but item-0 now wraps to 4 rows, item-1 to 5 rows
|
|
897
|
-
const newLive = createSnapshot([4, 5]);
|
|
898
|
-
const reflowed = reflowTimelineViewport(browsing, newLive);
|
|
899
|
-
|
|
900
|
-
assert.equal(reflowed.followTail, false);
|
|
901
|
-
// item-0 is now 4 rows. rowWithinItem was 1, still 1 in new layout.
|
|
902
|
-
// new anchorRow = 0 (rows before item-0) + 1 = 1
|
|
903
|
-
assert.equal(reflowed.anchorRow, 1);
|
|
904
|
-
assert.equal(reflowed.frozenSnapshot?.itemCount, 2);
|
|
905
|
-
assert.equal(reflowed.frozenSnapshot?.totalRows, 9); // 4 + 5
|
|
906
|
-
});
|
|
907
|
-
|
|
908
|
-
test("reflowTimelineViewport clamps rowWithinItem when width increases (less wrapping)", () => {
|
|
909
|
-
// Old layout: item-0 has 5 rows. Anchor at row 4 (last row of item-0).
|
|
910
|
-
const oldFrozen = createSnapshot([5, 3]);
|
|
911
|
-
const browsing: TimelineViewportState = {
|
|
912
|
-
anchorRow: 4,
|
|
913
|
-
followTail: false,
|
|
914
|
-
unseenItems: 0,
|
|
915
|
-
unseenRows: 0,
|
|
916
|
-
frozenSnapshot: oldFrozen,
|
|
917
|
-
};
|
|
918
|
-
|
|
919
|
-
// New layout (wider): item-0 now wraps to only 2 rows (less wrapping), item-1 to 1 row
|
|
920
|
-
const newLive = createSnapshot([2, 1]);
|
|
921
|
-
const reflowed = reflowTimelineViewport(browsing, newLive);
|
|
922
|
-
|
|
923
|
-
assert.equal(reflowed.followTail, false);
|
|
924
|
-
// rowWithinItem was 4, but item-0 only has 2 rows → clamped to 1 (last row)
|
|
925
|
-
// new anchorRow = 0 + 1 = 1
|
|
926
|
-
assert.equal(reflowed.anchorRow, 1);
|
|
927
|
-
assert.equal(reflowed.frozenSnapshot?.totalRows, 3); // 2 + 1
|
|
928
|
-
});
|
|
929
|
-
|
|
930
|
-
test("reflowTimelineViewport preserves unseenItems and unseenRows after width change", () => {
|
|
931
|
-
// User scrolled up when there were 2 items. One more item arrived since then.
|
|
932
|
-
const oldFrozen = createSnapshot([2, 3]);
|
|
933
|
-
const browsing: TimelineViewportState = {
|
|
934
|
-
anchorRow: 4, // last row of item-1 in old frozen
|
|
935
|
-
followTail: false,
|
|
936
|
-
unseenItems: 1,
|
|
937
|
-
unseenRows: 2,
|
|
938
|
-
frozenSnapshot: oldFrozen,
|
|
939
|
-
};
|
|
940
|
-
|
|
941
|
-
// New layout: 3 items (frozen 2 + 1 unseen), all at new width
|
|
942
|
-
const newLive = createSnapshot([3, 4, 2]); // 9 total
|
|
943
|
-
const reflowed = reflowTimelineViewport(browsing, newLive);
|
|
944
|
-
|
|
945
|
-
assert.equal(reflowed.followTail, false);
|
|
946
|
-
// frozenItemCount = 2 → new frozen uses first 2 items from newLive: [3, 4]
|
|
947
|
-
assert.equal(reflowed.frozenSnapshot?.itemCount, 2);
|
|
948
|
-
assert.equal(reflowed.frozenSnapshot?.totalRows, 7); // 3 + 4
|
|
949
|
-
// unseenItems = 3 - 2 = 1
|
|
950
|
-
assert.equal(reflowed.unseenItems, 1);
|
|
951
|
-
// unseenRows = 9 - 7 = 2
|
|
952
|
-
assert.equal(reflowed.unseenRows, 2);
|
|
953
|
-
});
|
|
954
|
-
|
|
955
|
-
test("reflowTimelineViewport does not snap to bottom during active streaming", () => {
|
|
956
|
-
// User scrolled up mid-stream. frozenSnapshot has 3 items (includes partial assistant).
|
|
957
|
-
const oldFrozen = createSnapshot([1, 2, 3]);
|
|
958
|
-
const browsing: TimelineViewportState = {
|
|
959
|
-
anchorRow: 2, // within item-1 (rows 1-2) → rowWithinItem = 1
|
|
960
|
-
followTail: false,
|
|
961
|
-
unseenItems: 0,
|
|
962
|
-
unseenRows: 0,
|
|
963
|
-
frozenSnapshot: oldFrozen,
|
|
964
|
-
};
|
|
965
|
-
|
|
966
|
-
// Width change arrives mid-stream: liveSnapshot has grown (assistant has more content)
|
|
967
|
-
// Items 0..2 from frozenSnapshot, item-2 now larger due to more streaming content
|
|
968
|
-
const newLive = createSnapshot([1, 2, 6]);
|
|
969
|
-
const reflowed = reflowTimelineViewport(browsing, newLive);
|
|
970
|
-
|
|
971
|
-
// Must NOT snap to bottom
|
|
972
|
-
assert.equal(reflowed.followTail, false);
|
|
973
|
-
// item-1 has 2 rows in new layout → rowWithinItem=1 preserved
|
|
974
|
-
// anchorRow = item-0 (1 row) + 1 = 2
|
|
975
|
-
assert.equal(reflowed.anchorRow, 2);
|
|
976
|
-
assert.equal(reflowed.frozenSnapshot?.itemCount, 3);
|
|
977
|
-
});
|
|
978
|
-
|
|
979
|
-
// ─── selectTimelineRows: height-change stability ──────────────────────────────
|
|
980
|
-
|
|
981
|
-
test("height increase while scrolled up shows more content above without snapping to bottom", () => {
|
|
982
|
-
const snapshot = createSnapshot([3, 3, 3, 3]); // 12 rows, 4 items
|
|
983
|
-
// User is at anchorRow=8 (last row of item-2), viewing rows 5-8 at viewportRows=4
|
|
984
|
-
const browsing: TimelineViewportState = {
|
|
985
|
-
anchorRow: 8,
|
|
986
|
-
followTail: false,
|
|
987
|
-
unseenItems: 0,
|
|
988
|
-
unseenRows: 0,
|
|
989
|
-
frozenSnapshot: snapshot,
|
|
990
|
-
};
|
|
991
|
-
|
|
992
|
-
const smallViewport = selectTimelineRows(snapshot, browsing, 4);
|
|
993
|
-
assert.equal(smallViewport.window.startRow, 5);
|
|
994
|
-
assert.equal(smallViewport.window.endRow, 9);
|
|
995
|
-
|
|
996
|
-
// Terminal grows to 8 rows — more content visible above, bottom anchor preserved
|
|
997
|
-
const largeViewport = selectTimelineRows(snapshot, browsing, 8);
|
|
998
|
-
assert.equal(largeViewport.window.startRow, 1);
|
|
999
|
-
assert.equal(largeViewport.window.endRow, 9);
|
|
1000
|
-
// Confirm not snapped to bottom
|
|
1001
|
-
assert.notEqual(largeViewport.window.endRow, snapshot.totalRows);
|
|
1002
|
-
});
|
|
1003
|
-
|
|
1004
|
-
test("height decrease while scrolled up preserves bottom anchor", () => {
|
|
1005
|
-
const snapshot = createSnapshot([3, 3, 3, 3]); // 12 rows
|
|
1006
|
-
const browsing: TimelineViewportState = {
|
|
1007
|
-
anchorRow: 8,
|
|
1008
|
-
followTail: false,
|
|
1009
|
-
unseenItems: 0,
|
|
1010
|
-
unseenRows: 0,
|
|
1011
|
-
frozenSnapshot: snapshot,
|
|
1012
|
-
};
|
|
1013
|
-
|
|
1014
|
-
// Viewport shrinks from 4 to 2 rows
|
|
1015
|
-
const shrunk = selectTimelineRows(snapshot, browsing, 2);
|
|
1016
|
-
// Bottom (anchorRow=8) is preserved; only 2 rows visible
|
|
1017
|
-
assert.equal(shrunk.window.endRow, 9);
|
|
1018
|
-
assert.equal(shrunk.window.startRow, 7);
|
|
1019
|
-
assert.equal(shrunk.visibleRows.length, 2);
|
|
1020
|
-
});
|
|
1021
|
-
|
|
1022
|
-
// ─── streaming while detached does not jump to bottom ────────────────────────
|
|
1023
|
-
|
|
1024
|
-
test("streaming deltas while detached do not move the viewport to bottom", () => {
|
|
1025
|
-
const initial = createSnapshot([1, 1, 1, 1]); // 4 items, 4 rows
|
|
1026
|
-
// User pages up — frozen at initial snapshot
|
|
1027
|
-
const browsing = pageUpTimelineViewport(createFollowTailViewport(initial.totalRows), initial, 3);
|
|
1028
|
-
assert.equal(browsing.followTail, false);
|
|
1029
|
-
|
|
1030
|
-
// Multiple streaming deltas arrive (same width — syncTimelineViewport path)
|
|
1031
|
-
const afterDelta1 = syncTimelineViewport(browsing, createSnapshot([1, 1, 1, 1, 2]));
|
|
1032
|
-
const afterDelta2 = syncTimelineViewport(afterDelta1, createSnapshot([1, 1, 1, 1, 4]));
|
|
1033
|
-
const afterDelta3 = syncTimelineViewport(afterDelta2, createSnapshot([1, 1, 1, 1, 6]));
|
|
1034
|
-
|
|
1035
|
-
// Must remain detached throughout
|
|
1036
|
-
assert.equal(afterDelta3.followTail, false);
|
|
1037
|
-
assert.equal(afterDelta3.frozenSnapshot?.itemCount, 4);
|
|
1038
|
-
|
|
1039
|
-
// The visible rows are still from the frozen snapshot, not the live tail
|
|
1040
|
-
const selected = selectTimelineRows(createSnapshot([1, 1, 1, 1, 6]), afterDelta3, 3);
|
|
1041
|
-
assert.equal(selected.sourceSnapshot.itemCount, 4);
|
|
1042
|
-
assert.notEqual(selected.window.endRow, 11); // not snapped to live tail
|
|
1043
|
-
});
|
|
1044
|
-
|
|
1045
|
-
test("scrolling down to the frozen tail with pending unseen content resumes live follow", () => {
|
|
1046
|
-
const initial = createSnapshot([2, 2]);
|
|
1047
|
-
const browsing = scrollTimelineViewport(
|
|
1048
|
-
createFollowTailViewport(initial.totalRows),
|
|
1049
|
-
initial,
|
|
1050
|
-
4,
|
|
1051
|
-
-2, // scroll up 2 rows
|
|
1052
|
-
);
|
|
1053
|
-
assert.equal(browsing.followTail, false);
|
|
1054
|
-
|
|
1055
|
-
const updated = createSnapshot([2, 2, 3]);
|
|
1056
|
-
const withNew = syncTimelineViewport(browsing, updated);
|
|
1057
|
-
assert.equal(withNew.unseenItems, 1);
|
|
1058
|
-
|
|
1059
|
-
// Scroll down past frozen tail → resume follow
|
|
1060
|
-
const resumed = pageDownTimelineViewport(withNew, updated, 4);
|
|
1061
|
-
assert.equal(resumed.followTail, true);
|
|
1062
|
-
assert.equal(resumed.frozenSnapshot, null);
|
|
1063
|
-
});
|
|
1064
|
-
|
|
1065
|
-
// ─── action event command normalization in timeline render ───────────────────
|
|
1066
|
-
|
|
1067
|
-
function makeCompletedRunWithTool(turnId: number, command: string): TimelineEvent[] {
|
|
1068
|
-
return [
|
|
1069
|
-
{
|
|
1070
|
-
id: 1,
|
|
1071
|
-
type: "user",
|
|
1072
|
-
createdAt: 1,
|
|
1073
|
-
prompt: "Do something",
|
|
1074
|
-
turnId,
|
|
1075
|
-
},
|
|
1076
|
-
{
|
|
1077
|
-
id: 2,
|
|
1078
|
-
type: "run",
|
|
1079
|
-
createdAt: 2,
|
|
1080
|
-
startedAt: 2,
|
|
1081
|
-
durationMs: 500,
|
|
1082
|
-
backendId: "codex-subprocess",
|
|
1083
|
-
backendLabel: "Codexa",
|
|
1084
|
-
runtime: TEST_RUNTIME,
|
|
1085
|
-
prompt: "Do something",
|
|
1086
|
-
progressEntries: [],
|
|
1087
|
-
status: "completed",
|
|
1088
|
-
summary: "Completed",
|
|
1089
|
-
truncatedOutput: false,
|
|
1090
|
-
toolActivities: [
|
|
1091
|
-
{
|
|
1092
|
-
id: "tool-1",
|
|
1093
|
-
command,
|
|
1094
|
-
status: "completed",
|
|
1095
|
-
startedAt: 2,
|
|
1096
|
-
completedAt: 3,
|
|
1097
|
-
},
|
|
1098
|
-
],
|
|
1099
|
-
activity: [],
|
|
1100
|
-
touchedFileCount: 0,
|
|
1101
|
-
errorMessage: null,
|
|
1102
|
-
turnId,
|
|
1103
|
-
},
|
|
1104
|
-
{
|
|
1105
|
-
id: 3,
|
|
1106
|
-
type: "assistant",
|
|
1107
|
-
createdAt: 4,
|
|
1108
|
-
content: "Done.",
|
|
1109
|
-
contentChunks: [],
|
|
1110
|
-
turnId,
|
|
1111
|
-
},
|
|
1112
|
-
];
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
function makeChronologicalTurnEvents(
|
|
1116
|
-
turnId: number,
|
|
1117
|
-
runOverrides: Partial<Extract<TimelineEvent, { type: "run" }>>,
|
|
1118
|
-
assistantContent = "",
|
|
1119
|
-
): TimelineEvent[] {
|
|
1120
|
-
const run: Extract<TimelineEvent, { type: "run" }> = {
|
|
1121
|
-
id: 2,
|
|
1122
|
-
type: "run",
|
|
1123
|
-
createdAt: 2,
|
|
1124
|
-
startedAt: 2,
|
|
1125
|
-
durationMs: 500,
|
|
1126
|
-
backendId: "codex-subprocess",
|
|
1127
|
-
backendLabel: "Codexa",
|
|
1128
|
-
runtime: TEST_RUNTIME,
|
|
1129
|
-
prompt: "Do something",
|
|
1130
|
-
progressEntries: [],
|
|
1131
|
-
status: "completed",
|
|
1132
|
-
summary: "Completed",
|
|
1133
|
-
truncatedOutput: false,
|
|
1134
|
-
toolActivities: [],
|
|
1135
|
-
activity: [],
|
|
1136
|
-
touchedFileCount: 0,
|
|
1137
|
-
errorMessage: null,
|
|
1138
|
-
turnId,
|
|
1139
|
-
...runOverrides,
|
|
1140
|
-
};
|
|
1141
|
-
|
|
1142
|
-
return [
|
|
1143
|
-
{
|
|
1144
|
-
id: 1,
|
|
1145
|
-
type: "user",
|
|
1146
|
-
createdAt: 1,
|
|
1147
|
-
prompt: "Do something",
|
|
1148
|
-
turnId,
|
|
1149
|
-
},
|
|
1150
|
-
run,
|
|
1151
|
-
{
|
|
1152
|
-
id: 3,
|
|
1153
|
-
type: "assistant",
|
|
1154
|
-
createdAt: 4,
|
|
1155
|
-
content: assistantContent,
|
|
1156
|
-
contentChunks: [],
|
|
1157
|
-
turnId,
|
|
1158
|
-
},
|
|
1159
|
-
];
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
function renderJoinedTurn(
|
|
1163
|
-
events: TimelineEvent[],
|
|
1164
|
-
turnId: number,
|
|
1165
|
-
width = 90,
|
|
1166
|
-
options: { workspaceRoot?: string | null } = {},
|
|
1167
|
-
): string {
|
|
1168
|
-
const items = buildTimelineItems(events);
|
|
1169
|
-
const renderItems = buildStaticRenderItems(items, [turnId], null, null, null);
|
|
1170
|
-
const snapshot = buildTimelineSnapshot(renderItems, { totalWidth: width, workspaceRoot: options.workspaceRoot });
|
|
1171
|
-
return snapshot.rows.map((row) => row.spans.map((span) => span.text).join("")).join("\n");
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
test("unified stream renders generated final plan after action blocks", () => {
|
|
1175
|
-
const joined = renderJoinedTurn(makeChronologicalTurnEvents(299, {
|
|
1176
|
-
plan: {
|
|
1177
|
-
id: "plan-2",
|
|
1178
|
-
streamSeq: 3,
|
|
1179
|
-
chunks: ["1. Inspect the current app structure\n2. Render the generated plan visibly"],
|
|
1180
|
-
status: "completed",
|
|
1181
|
-
startedAt: 2,
|
|
1182
|
-
},
|
|
1183
|
-
toolActivities: [{
|
|
1184
|
-
id: "tool-1",
|
|
1185
|
-
command: "Get-Content src/app.tsx",
|
|
1186
|
-
status: "completed",
|
|
1187
|
-
startedAt: 10,
|
|
1188
|
-
completedAt: 40,
|
|
1189
|
-
streamSeq: 2,
|
|
1190
|
-
}],
|
|
1191
|
-
streamItems: [
|
|
1192
|
-
{ streamSeq: 2, kind: "action", refId: "tool-1" },
|
|
1193
|
-
{ streamSeq: 3, kind: "plan", refId: "plan-2" },
|
|
1194
|
-
],
|
|
1195
|
-
lastStreamSeq: 3,
|
|
1196
|
-
}), 299);
|
|
1197
|
-
|
|
1198
|
-
assert.match(joined, /╭── Plan/);
|
|
1199
|
-
assert.match(joined, /│ 1\. Inspect the current app structure/);
|
|
1200
|
-
assert.match(joined, /╰/);
|
|
1201
|
-
assert.match(joined, /╭── action/);
|
|
1202
|
-
assert.ok(joined.indexOf("action") < joined.indexOf("Plan"));
|
|
1203
|
-
assert.ok(joined.indexOf("Read file") < joined.indexOf("Render the generated plan visibly"));
|
|
1204
|
-
});
|
|
1205
|
-
|
|
1206
|
-
test("unified stream renders approved execution plan with approved badge", () => {
|
|
1207
|
-
const approvedPlan = "1. Apply the selected changes\n2. Run tests";
|
|
1208
|
-
const turnId = 9305;
|
|
1209
|
-
const joined = renderJoinedTurn(makeChronologicalTurnEvents(turnId, {
|
|
1210
|
-
plan: {
|
|
1211
|
-
id: "plan-2",
|
|
1212
|
-
streamSeq: 1,
|
|
1213
|
-
chunks: [approvedPlan],
|
|
1214
|
-
status: "completed",
|
|
1215
|
-
startedAt: 2,
|
|
1216
|
-
},
|
|
1217
|
-
approvedPlan,
|
|
1218
|
-
streamItems: [
|
|
1219
|
-
{ streamSeq: 1, kind: "plan", refId: "plan-2" },
|
|
1220
|
-
],
|
|
1221
|
-
lastStreamSeq: 1,
|
|
1222
|
-
}), turnId);
|
|
1223
|
-
|
|
1224
|
-
assert.match(joined, /╭── Plan/);
|
|
1225
|
-
assert.match(joined, /approved/);
|
|
1226
|
-
assert.doesNotMatch(joined, /Review Plan/);
|
|
1227
|
-
assert.doesNotMatch(joined, /Implementation Plan/);
|
|
1228
|
-
});
|
|
1229
|
-
|
|
1230
|
-
test("unified stream hides workspace paths in finalized plan snapshots", () => {
|
|
1231
|
-
const turnId = 9306;
|
|
1232
|
-
const joined = renderJoinedTurn(makeChronologicalTurnEvents(turnId, {
|
|
1233
|
-
plan: {
|
|
1234
|
-
id: "plan-2",
|
|
1235
|
-
streamSeq: 1,
|
|
1236
|
-
chunks: ["Files:\n- C:\\Development\\Project\\src\\app.tsx"],
|
|
1237
|
-
status: "completed",
|
|
1238
|
-
startedAt: 2,
|
|
1239
|
-
},
|
|
1240
|
-
streamItems: [
|
|
1241
|
-
{ streamSeq: 1, kind: "plan", refId: "plan-2" },
|
|
1242
|
-
],
|
|
1243
|
-
lastStreamSeq: 1,
|
|
1244
|
-
}), turnId, 90, { workspaceRoot: "C:\\Development\\Project" });
|
|
1245
|
-
|
|
1246
|
-
assert.match(joined, /src\/app\.tsx/);
|
|
1247
|
-
assert.doesNotMatch(joined, /C:\\Development/);
|
|
1248
|
-
});
|
|
1249
|
-
|
|
1250
|
-
test("long draft plan remains timeline rows that can be scrolled", () => {
|
|
1251
|
-
const longPlan = [
|
|
1252
|
-
"Files:",
|
|
1253
|
-
...Array.from({ length: 36 }, (_, index) => `- src/file-${index + 1}.ts Wire file ${index + 1}.`),
|
|
1254
|
-
"",
|
|
1255
|
-
"Steps:",
|
|
1256
|
-
...Array.from({ length: 36 }, (_, index) => `${index + 1}. Complete step ${index + 1}.`),
|
|
1257
|
-
].join("\n");
|
|
1258
|
-
const turnId = 9307;
|
|
1259
|
-
const events = makeChronologicalTurnEvents(turnId, {
|
|
1260
|
-
plan: {
|
|
1261
|
-
id: "plan-2",
|
|
1262
|
-
streamSeq: 1,
|
|
1263
|
-
chunks: [longPlan],
|
|
1264
|
-
status: "completed",
|
|
1265
|
-
startedAt: 2,
|
|
1266
|
-
},
|
|
1267
|
-
streamItems: [
|
|
1268
|
-
{ streamSeq: 1, kind: "plan", refId: "plan-2" },
|
|
1269
|
-
],
|
|
1270
|
-
lastStreamSeq: 1,
|
|
1271
|
-
});
|
|
1272
|
-
const items = buildTimelineItems(events);
|
|
1273
|
-
const renderItems = buildStaticRenderItems(items, [turnId], null, null, null);
|
|
1274
|
-
const snapshot = buildTimelineSnapshot(renderItems, { totalWidth: 100 });
|
|
1275
|
-
const firstPage = selectTimelineRows(snapshot, { ...createFollowTailViewport(snapshot.totalRows), followTail: false, anchorRow: 15 }, 12);
|
|
1276
|
-
const tailPage = selectTimelineRows(snapshot, createFollowTailViewport(snapshot.totalRows), 12);
|
|
1277
|
-
const allRows = snapshot.rows.map((row) => row.spans.map((span) => span.text).join("")).join("\n");
|
|
1278
|
-
|
|
1279
|
-
assert.ok(snapshot.totalRows > 72, "long plan should add scrollback rows to the timeline");
|
|
1280
|
-
assert.match(allRows, /Plan/);
|
|
1281
|
-
assert.match(firstPage.visibleRows.map((row) => row.spans.map((span) => span.text).join("")).join("\n"), /src\/file-/);
|
|
1282
|
-
assert.match(tailPage.visibleRows.map((row) => row.spans.map((span) => span.text).join("")).join("\n"), /36\. Complete step 36\./);
|
|
1283
|
-
});
|
|
1284
|
-
|
|
1285
|
-
test("follow-tail viewport shows generated final plan at bottom after prior action rows", () => {
|
|
1286
|
-
const longPlan = [
|
|
1287
|
-
"Files:",
|
|
1288
|
-
...Array.from({ length: 12 }, (_, index) => `- src/file-${index + 1}.ts Update file ${index + 1}.`),
|
|
1289
|
-
"",
|
|
1290
|
-
"Steps:",
|
|
1291
|
-
...Array.from({ length: 20 }, (_, index) => `${index + 1}. Final plan step ${index + 1}.`),
|
|
1292
|
-
].join("\n");
|
|
1293
|
-
const turnId = 9308;
|
|
1294
|
-
const events = makeChronologicalTurnEvents(turnId, {
|
|
1295
|
-
plan: {
|
|
1296
|
-
id: "plan-2",
|
|
1297
|
-
streamSeq: 3,
|
|
1298
|
-
chunks: [longPlan],
|
|
1299
|
-
status: "completed",
|
|
1300
|
-
startedAt: 2,
|
|
1301
|
-
},
|
|
1302
|
-
toolActivities: [{
|
|
1303
|
-
id: "tool-1",
|
|
1304
|
-
command: "Get-Content src/app.tsx",
|
|
1305
|
-
status: "completed",
|
|
1306
|
-
startedAt: 10,
|
|
1307
|
-
completedAt: 40,
|
|
1308
|
-
streamSeq: 2,
|
|
1309
|
-
}],
|
|
1310
|
-
streamItems: [
|
|
1311
|
-
{ streamSeq: 2, kind: "action", refId: "tool-1" },
|
|
1312
|
-
{ streamSeq: 3, kind: "plan", refId: "plan-2" },
|
|
1313
|
-
],
|
|
1314
|
-
lastStreamSeq: 3,
|
|
1315
|
-
});
|
|
1316
|
-
const items = buildTimelineItems(events);
|
|
1317
|
-
const renderItems = buildStaticRenderItems(items, [turnId], null, null, null);
|
|
1318
|
-
const snapshot = buildTimelineSnapshot(renderItems, { totalWidth: 100 });
|
|
1319
|
-
const tailPage = selectTimelineRows(snapshot, createFollowTailViewport(snapshot.totalRows), 10);
|
|
1320
|
-
const tailText = tailPage.visibleRows.map((row) => row.spans.map((span) => span.text).join("")).join("\n");
|
|
1321
|
-
const allText = snapshot.rows.map((row) => row.spans.map((span) => span.text).join("")).join("\n");
|
|
1322
|
-
|
|
1323
|
-
assert.ok(allText.indexOf("Read file") < allText.indexOf("Plan"));
|
|
1324
|
-
assert.match(tailText, /20\. Final plan step 20\./);
|
|
1325
|
-
assert.doesNotMatch(tailText, /Read file/);
|
|
1326
|
-
});
|
|
1327
|
-
|
|
1328
|
-
test("unified stream renders action before response by stream sequence", () => {
|
|
1329
|
-
const joined = renderJoinedTurn(makeChronologicalTurnEvents(300, {
|
|
1330
|
-
toolActivities: [{
|
|
1331
|
-
id: "tool-1",
|
|
1332
|
-
command: "rg --files",
|
|
1333
|
-
status: "completed",
|
|
1334
|
-
startedAt: 10,
|
|
1335
|
-
completedAt: 429,
|
|
1336
|
-
streamSeq: 1,
|
|
1337
|
-
}],
|
|
1338
|
-
responseSegments: [{
|
|
1339
|
-
id: "response-1",
|
|
1340
|
-
streamSeq: 2,
|
|
1341
|
-
chunks: ["Purpose\n5-Date Verification explains the two-block calendar puzzle."],
|
|
1342
|
-
status: "completed",
|
|
1343
|
-
startedAt: 20,
|
|
1344
|
-
}],
|
|
1345
|
-
streamItems: [
|
|
1346
|
-
{ streamSeq: 1, kind: "action", refId: "tool-1" },
|
|
1347
|
-
{ streamSeq: 2, kind: "response", refId: "response-1" },
|
|
1348
|
-
],
|
|
1349
|
-
lastStreamSeq: 2,
|
|
1350
|
-
}), 300);
|
|
1351
|
-
|
|
1352
|
-
assert.ok(joined.indexOf("action") < joined.indexOf("Purpose"));
|
|
1353
|
-
assert.match(joined, /List files/);
|
|
1354
|
-
assert.match(joined, /Codex/);
|
|
1355
|
-
assert.doesNotMatch(joined, /^\s*response\b/m);
|
|
1356
|
-
});
|
|
1357
|
-
|
|
1358
|
-
test("unified stream preserves thinking action response ordering", () => {
|
|
1359
|
-
const joined = renderJoinedTurn(makeChronologicalTurnEvents(301, {
|
|
1360
|
-
progressEntries: [{
|
|
1361
|
-
id: "reason-1",
|
|
1362
|
-
source: "reasoning",
|
|
1363
|
-
text: "I need to inspect the project files.",
|
|
1364
|
-
sequence: 1,
|
|
1365
|
-
createdAt: 1,
|
|
1366
|
-
updatedAt: 1,
|
|
1367
|
-
pendingNewlineCount: 0,
|
|
1368
|
-
blocks: [{
|
|
1369
|
-
id: "reason-1-block-1",
|
|
1370
|
-
text: "I need to inspect the project files.",
|
|
1371
|
-
sequence: 1,
|
|
1372
|
-
createdAt: 1,
|
|
1373
|
-
updatedAt: 1,
|
|
1374
|
-
status: "completed",
|
|
1375
|
-
streamSeq: 1,
|
|
1376
|
-
}],
|
|
1377
|
-
}],
|
|
1378
|
-
toolActivities: [{
|
|
1379
|
-
id: "tool-1",
|
|
1380
|
-
command: "Get-Content README.md",
|
|
1381
|
-
status: "completed",
|
|
1382
|
-
startedAt: 10,
|
|
1383
|
-
completedAt: 426,
|
|
1384
|
-
streamSeq: 2,
|
|
1385
|
-
}],
|
|
1386
|
-
responseSegments: [{
|
|
1387
|
-
id: "response-1",
|
|
1388
|
-
streamSeq: 3,
|
|
1389
|
-
chunks: ["Purpose\n5-Date Verification is an interactive math app."],
|
|
1390
|
-
status: "completed",
|
|
1391
|
-
startedAt: 20,
|
|
1392
|
-
}],
|
|
1393
|
-
streamItems: [
|
|
1394
|
-
{ streamSeq: 1, kind: "thinking", refId: "reason-1-block-1" },
|
|
1395
|
-
{ streamSeq: 2, kind: "action", refId: "tool-1" },
|
|
1396
|
-
{ streamSeq: 3, kind: "response", refId: "response-1" },
|
|
1397
|
-
],
|
|
1398
|
-
lastStreamSeq: 3,
|
|
1399
|
-
}), 301);
|
|
1400
|
-
|
|
1401
|
-
assert.ok(joined.indexOf("I need to inspect") < joined.indexOf("action"));
|
|
1402
|
-
assert.ok(joined.indexOf("action") < joined.indexOf("Purpose"));
|
|
1403
|
-
assert.match(joined, /Codex/);
|
|
1404
|
-
assert.doesNotMatch(joined, /^\s*response\b/m);
|
|
1405
|
-
});
|
|
1406
|
-
|
|
1407
|
-
test("unified stream preserves response action response interleaving", () => {
|
|
1408
|
-
const joined = renderJoinedTurn(makeChronologicalTurnEvents(302, {
|
|
1409
|
-
toolActivities: [{
|
|
1410
|
-
id: "tool-1",
|
|
1411
|
-
command: "Get-Content src\\App.tsx",
|
|
1412
|
-
status: "completed",
|
|
1413
|
-
startedAt: 10,
|
|
1414
|
-
completedAt: 428,
|
|
1415
|
-
streamSeq: 2,
|
|
1416
|
-
}],
|
|
1417
|
-
responseSegments: [
|
|
1418
|
-
{
|
|
1419
|
-
id: "response-1",
|
|
1420
|
-
streamSeq: 1,
|
|
1421
|
-
chunks: ["First segment."],
|
|
1422
|
-
status: "completed",
|
|
1423
|
-
startedAt: 1,
|
|
1424
|
-
},
|
|
1425
|
-
{
|
|
1426
|
-
id: "response-2",
|
|
1427
|
-
streamSeq: 3,
|
|
1428
|
-
chunks: ["Second segment."],
|
|
1429
|
-
status: "completed",
|
|
1430
|
-
startedAt: 2,
|
|
1431
|
-
},
|
|
1432
|
-
],
|
|
1433
|
-
streamItems: [
|
|
1434
|
-
{ streamSeq: 1, kind: "response", refId: "response-1" },
|
|
1435
|
-
{ streamSeq: 2, kind: "action", refId: "tool-1" },
|
|
1436
|
-
{ streamSeq: 3, kind: "response", refId: "response-2" },
|
|
1437
|
-
],
|
|
1438
|
-
lastStreamSeq: 3,
|
|
1439
|
-
}), 302);
|
|
1440
|
-
|
|
1441
|
-
assert.ok(joined.indexOf("First segment") < joined.indexOf("action"));
|
|
1442
|
-
assert.ok(joined.indexOf("action") < joined.indexOf("Second segment"));
|
|
1443
|
-
assert.doesNotMatch(joined, /^\s*response\b/m);
|
|
1444
|
-
});
|
|
1445
|
-
|
|
1446
|
-
test("stream renders Codex text outside bordered action cards", () => {
|
|
1447
|
-
const joined = renderJoinedTurn(makeChronologicalTurnEvents(305, {
|
|
1448
|
-
toolActivities: [{
|
|
1449
|
-
id: "tool-1",
|
|
1450
|
-
command: "Get-Content README.md",
|
|
1451
|
-
status: "completed",
|
|
1452
|
-
startedAt: 10,
|
|
1453
|
-
completedAt: 426,
|
|
1454
|
-
streamSeq: 2,
|
|
1455
|
-
}],
|
|
1456
|
-
responseSegments: [
|
|
1457
|
-
{
|
|
1458
|
-
id: "response-1",
|
|
1459
|
-
streamSeq: 1,
|
|
1460
|
-
chunks: ["I am checking the README."],
|
|
1461
|
-
status: "completed",
|
|
1462
|
-
startedAt: 1,
|
|
1463
|
-
},
|
|
1464
|
-
{
|
|
1465
|
-
id: "response-2",
|
|
1466
|
-
streamSeq: 3,
|
|
1467
|
-
chunks: ["Purpose: this project wraps Codex in a terminal UI."],
|
|
1468
|
-
status: "completed",
|
|
1469
|
-
startedAt: 2,
|
|
1470
|
-
},
|
|
1471
|
-
],
|
|
1472
|
-
streamItems: [
|
|
1473
|
-
{ streamSeq: 1, kind: "response", refId: "response-1" },
|
|
1474
|
-
{ streamSeq: 2, kind: "action", refId: "tool-1" },
|
|
1475
|
-
{ streamSeq: 3, kind: "response", refId: "response-2" },
|
|
1476
|
-
],
|
|
1477
|
-
lastStreamSeq: 3,
|
|
1478
|
-
}), 305);
|
|
1479
|
-
|
|
1480
|
-
const codexLine = joined.split("\n").find((line) => line.includes("I am checking the README."));
|
|
1481
|
-
const actionLine = joined.split("\n").find((line) => line.includes("Read file"));
|
|
1482
|
-
|
|
1483
|
-
assert.match(joined, /Codex/);
|
|
1484
|
-
assert.match(joined, /╭── action/);
|
|
1485
|
-
assert.match(joined, /Read file/);
|
|
1486
|
-
assert.ok(codexLine && !codexLine.includes("│"), "Codex narration should not be inside a bordered row");
|
|
1487
|
-
assert.ok(actionLine && actionLine.includes("│"), "action execution should keep the bordered card visual");
|
|
1488
|
-
assert.doesNotMatch(joined, /^\s*response\b/m);
|
|
1489
|
-
});
|
|
1490
|
-
|
|
1491
|
-
test("consecutive actions render as separate bordered action cards", () => {
|
|
1492
|
-
const joined = renderJoinedTurn(makeChronologicalTurnEvents(306, {
|
|
1493
|
-
toolActivities: [
|
|
1494
|
-
{
|
|
1495
|
-
id: "tool-1",
|
|
1496
|
-
command: "Get-ChildItem -Force",
|
|
1497
|
-
status: "completed",
|
|
1498
|
-
startedAt: 10,
|
|
1499
|
-
completedAt: 488,
|
|
1500
|
-
streamSeq: 1,
|
|
1501
|
-
},
|
|
1502
|
-
{
|
|
1503
|
-
id: "tool-2",
|
|
1504
|
-
command: "Get-Content src\\App.tsx",
|
|
1505
|
-
status: "completed",
|
|
1506
|
-
startedAt: 20,
|
|
1507
|
-
completedAt: 452,
|
|
1508
|
-
streamSeq: 2,
|
|
1509
|
-
},
|
|
1510
|
-
],
|
|
1511
|
-
streamItems: [
|
|
1512
|
-
{ streamSeq: 1, kind: "action", refId: "tool-1" },
|
|
1513
|
-
{ streamSeq: 2, kind: "action", refId: "tool-2" },
|
|
1514
|
-
],
|
|
1515
|
-
lastStreamSeq: 2,
|
|
1516
|
-
}), 306);
|
|
1517
|
-
|
|
1518
|
-
assert.equal((joined.match(/╭── action/g) ?? []).length, 2);
|
|
1519
|
-
assert.equal((joined.match(/│ ✓/g) ?? []).length, 2);
|
|
1520
|
-
assert.match(joined, /List files/);
|
|
1521
|
-
assert.match(joined, /Read file/);
|
|
1522
|
-
});
|
|
1523
|
-
|
|
1524
|
-
test("completed final response is not forced above earlier actions", () => {
|
|
1525
|
-
const joined = renderJoinedTurn(makeChronologicalTurnEvents(303, {
|
|
1526
|
-
toolActivities: [{
|
|
1527
|
-
id: "tool-1",
|
|
1528
|
-
command: "git status",
|
|
1529
|
-
status: "completed",
|
|
1530
|
-
startedAt: 10,
|
|
1531
|
-
completedAt: 20,
|
|
1532
|
-
streamSeq: 1,
|
|
1533
|
-
}],
|
|
1534
|
-
responseSegments: [{
|
|
1535
|
-
id: "response-1",
|
|
1536
|
-
streamSeq: 2,
|
|
1537
|
-
chunks: ["Done after checking status."],
|
|
1538
|
-
status: "completed",
|
|
1539
|
-
startedAt: 30,
|
|
1540
|
-
}],
|
|
1541
|
-
streamItems: [
|
|
1542
|
-
{ streamSeq: 1, kind: "action", refId: "tool-1" },
|
|
1543
|
-
{ streamSeq: 2, kind: "response", refId: "response-1" },
|
|
1544
|
-
],
|
|
1545
|
-
lastStreamSeq: 2,
|
|
1546
|
-
}), 303);
|
|
1547
|
-
|
|
1548
|
-
assert.ok(joined.indexOf("Check git status") < joined.indexOf("Done after checking status"));
|
|
1549
|
-
});
|
|
1550
|
-
|
|
1551
|
-
test("completed action/read-file rows remain before the final response", () => {
|
|
1552
|
-
const joined = renderJoinedTurn(makeChronologicalTurnEvents(307, {
|
|
1553
|
-
toolActivities: [
|
|
1554
|
-
{
|
|
1555
|
-
id: "tool-1",
|
|
1556
|
-
command: "Get-Content 5-Date-Verification.md",
|
|
1557
|
-
status: "completed",
|
|
1558
|
-
startedAt: 10,
|
|
1559
|
-
completedAt: 20,
|
|
1560
|
-
streamSeq: 1,
|
|
1561
|
-
},
|
|
1562
|
-
{
|
|
1563
|
-
id: "tool-2",
|
|
1564
|
-
command: "Get-Content README.md",
|
|
1565
|
-
status: "completed",
|
|
1566
|
-
startedAt: 21,
|
|
1567
|
-
completedAt: 30,
|
|
1568
|
-
streamSeq: 2,
|
|
1569
|
-
},
|
|
1570
|
-
],
|
|
1571
|
-
responseSegments: [{
|
|
1572
|
-
id: "response-1",
|
|
1573
|
-
streamSeq: 3,
|
|
1574
|
-
chunks: ["Final answer: 5-Date Verification explains the date-checking rule."],
|
|
1575
|
-
status: "completed",
|
|
1576
|
-
startedAt: 40,
|
|
1577
|
-
}],
|
|
1578
|
-
streamItems: [
|
|
1579
|
-
{ streamSeq: 1, kind: "action", refId: "tool-1" },
|
|
1580
|
-
{ streamSeq: 2, kind: "action", refId: "tool-2" },
|
|
1581
|
-
{ streamSeq: 3, kind: "response", refId: "response-1" },
|
|
1582
|
-
],
|
|
1583
|
-
lastStreamSeq: 3,
|
|
1584
|
-
}), 307);
|
|
1585
|
-
|
|
1586
|
-
assert.equal((joined.match(/╭── action/g) ?? []).length, 2);
|
|
1587
|
-
assert.equal((joined.match(/│ ✓/g) ?? []).length, 2);
|
|
1588
|
-
assert.ok(joined.indexOf("Read file") < joined.indexOf("Final answer"));
|
|
1589
|
-
});
|
|
1590
|
-
|
|
1591
|
-
test("finalize continuity viewport shows construction plus the beginning of the final answer", () => {
|
|
1592
|
-
const turnId = 308;
|
|
1593
|
-
const prompt = "What is the point of this file ie 5-Date Verification";
|
|
1594
|
-
const toolActivities = Array.from({ length: 6 }, (_, index) => ({
|
|
1595
|
-
id: `tool-${index + 1}`,
|
|
1596
|
-
command: `Get-Content date-file-${index + 1}.md`,
|
|
1597
|
-
status: "completed" as const,
|
|
1598
|
-
startedAt: 10 + index,
|
|
1599
|
-
completedAt: 20 + index,
|
|
1600
|
-
summary: `Read ${10 + index} lines`,
|
|
1601
|
-
streamSeq: index + 2,
|
|
1602
|
-
}));
|
|
1603
|
-
const progressEntry: RunProgressEntry = {
|
|
1604
|
-
id: "reason-1",
|
|
1605
|
-
source: "reasoning",
|
|
1606
|
-
text: "I need to inspect the date verification files.",
|
|
1607
|
-
sequence: 1,
|
|
1608
|
-
createdAt: 1,
|
|
1609
|
-
updatedAt: 1,
|
|
1610
|
-
pendingNewlineCount: 0,
|
|
1611
|
-
blocks: [{
|
|
1612
|
-
id: "reason-1-block-1",
|
|
1613
|
-
text: "I need to inspect the date verification files.",
|
|
1614
|
-
sequence: 1,
|
|
1615
|
-
createdAt: 1,
|
|
1616
|
-
updatedAt: 1,
|
|
1617
|
-
status: "completed",
|
|
1618
|
-
streamSeq: 1,
|
|
1619
|
-
}],
|
|
1620
|
-
};
|
|
1621
|
-
const userEvent: TimelineEvent = {
|
|
1622
|
-
id: 1,
|
|
1623
|
-
type: "user",
|
|
1624
|
-
createdAt: 1,
|
|
1625
|
-
prompt,
|
|
1626
|
-
turnId,
|
|
1627
|
-
};
|
|
1628
|
-
const runningRun: Extract<TimelineEvent, { type: "run" }> = {
|
|
1629
|
-
id: 2,
|
|
1630
|
-
type: "run",
|
|
1631
|
-
createdAt: 2,
|
|
1632
|
-
startedAt: 2,
|
|
1633
|
-
durationMs: null,
|
|
1634
|
-
backendId: "codex-subprocess",
|
|
1635
|
-
backendLabel: "Codexa",
|
|
1636
|
-
runtime: TEST_RUNTIME,
|
|
1637
|
-
prompt,
|
|
1638
|
-
progressEntries: [progressEntry],
|
|
1639
|
-
status: "running",
|
|
1640
|
-
summary: "Running",
|
|
1641
|
-
truncatedOutput: false,
|
|
1642
|
-
toolActivities,
|
|
1643
|
-
activity: [],
|
|
1644
|
-
touchedFileCount: 0,
|
|
1645
|
-
errorMessage: null,
|
|
1646
|
-
turnId,
|
|
1647
|
-
streamItems: [
|
|
1648
|
-
{ streamSeq: 1, kind: "thinking", refId: "reason-1-block-1" },
|
|
1649
|
-
...toolActivities.map((tool) => ({ streamSeq: tool.streamSeq!, kind: "action" as const, refId: tool.id })),
|
|
1650
|
-
],
|
|
1651
|
-
responseSegments: [],
|
|
1652
|
-
lastStreamSeq: 7,
|
|
1653
|
-
activeResponseSegmentId: null,
|
|
1654
|
-
};
|
|
1655
|
-
const finalAnswer = Array.from(
|
|
1656
|
-
{ length: 16 },
|
|
1657
|
-
(_, index) => `Final answer line ${index + 1}: explanation of 5-Date Verification.`,
|
|
1658
|
-
).join("\n");
|
|
1659
|
-
const finalizedRun: Extract<TimelineEvent, { type: "run" }> = {
|
|
1660
|
-
...runningRun,
|
|
1661
|
-
status: "completed",
|
|
1662
|
-
durationMs: 1000,
|
|
1663
|
-
responseSegments: [{
|
|
1664
|
-
id: "response-final-2-8",
|
|
1665
|
-
streamSeq: 8,
|
|
1666
|
-
chunks: [finalAnswer],
|
|
1667
|
-
status: "completed",
|
|
1668
|
-
startedAt: 40,
|
|
1669
|
-
}],
|
|
1670
|
-
streamItems: [
|
|
1671
|
-
...(runningRun.streamItems ?? []),
|
|
1672
|
-
{ streamSeq: 8, kind: "response", refId: "response-final-2-8" },
|
|
1673
|
-
],
|
|
1674
|
-
lastStreamSeq: 8,
|
|
1675
|
-
};
|
|
1676
|
-
const assistantEvent: TimelineEvent = {
|
|
1677
|
-
id: 3,
|
|
1678
|
-
type: "assistant",
|
|
1679
|
-
createdAt: 3,
|
|
1680
|
-
content: finalAnswer,
|
|
1681
|
-
contentChunks: [],
|
|
1682
|
-
turnId,
|
|
1683
|
-
};
|
|
1684
|
-
const activeItems = buildTimelineItems([userEvent, runningRun]);
|
|
1685
|
-
const activeRenderItems = buildActiveRenderItems(activeItems, [turnId], { kind: "THINKING", turnId });
|
|
1686
|
-
const activeSnapshot = buildTimelineSnapshot(activeRenderItems, { totalWidth: 90 });
|
|
1687
|
-
const finalItems = buildTimelineItems([userEvent, finalizedRun, assistantEvent]);
|
|
1688
|
-
const finalRenderItems = buildStaticRenderItems(finalItems, [turnId], null, null, null);
|
|
1689
|
-
const finalSnapshot = buildTimelineSnapshot(finalRenderItems, { totalWidth: 90 });
|
|
1690
|
-
const continuity = createFinalizeContinuityViewport(finalSnapshot, {
|
|
1691
|
-
previousTotalRows: activeSnapshot.totalRows,
|
|
1692
|
-
viewportRows: 18,
|
|
1693
|
-
});
|
|
1694
|
-
const visible = selectTimelineRows(finalSnapshot, continuity, 18).visibleRows
|
|
1695
|
-
.map((row) => row.spans.map((span) => span.text).join(""))
|
|
1696
|
-
.join("\n");
|
|
1697
|
-
|
|
1698
|
-
assert.equal(continuity.followTail, false);
|
|
1699
|
-
assert.notEqual(continuity.anchorRow, finalSnapshot.totalRows - 1);
|
|
1700
|
-
assert.match(visible, /╭── action/);
|
|
1701
|
-
assert.match(visible, /│ ✓/);
|
|
1702
|
-
assert.match(visible, /Read file/);
|
|
1703
|
-
assert.match(visible, /Final answer line 1/);
|
|
1704
|
-
assert.doesNotMatch(visible, /Final answer line 10/);
|
|
1705
|
-
});
|
|
1706
|
-
|
|
1707
|
-
test("raw stdout progress does not render as thinking in fallback sessions", () => {
|
|
1708
|
-
const joined = renderJoinedTurn(makeChronologicalTurnEvents(304, {
|
|
1709
|
-
progressEntries: [{
|
|
1710
|
-
id: "stdout-1",
|
|
1711
|
-
source: "stdout",
|
|
1712
|
-
text: "Directory: C:\\Users\\Example\\Project\n\nvitest.config.ts\nimport { useMemo } from \"react\";",
|
|
1713
|
-
sequence: 1,
|
|
1714
|
-
createdAt: 1,
|
|
1715
|
-
updatedAt: 1,
|
|
1716
|
-
pendingNewlineCount: 0,
|
|
1717
|
-
blocks: [{
|
|
1718
|
-
id: "stdout-1-block-1",
|
|
1719
|
-
text: "Directory: C:\\Users\\Example\\Project\n\nvitest.config.ts\nimport { useMemo } from \"react\";",
|
|
1720
|
-
sequence: 1,
|
|
1721
|
-
createdAt: 1,
|
|
1722
|
-
updatedAt: 1,
|
|
1723
|
-
status: "completed",
|
|
1724
|
-
}],
|
|
1725
|
-
}],
|
|
1726
|
-
responseSegments: [{
|
|
1727
|
-
id: "response-1",
|
|
1728
|
-
streamSeq: 1,
|
|
1729
|
-
chunks: ["Done."],
|
|
1730
|
-
status: "completed",
|
|
1731
|
-
startedAt: 2,
|
|
1732
|
-
}],
|
|
1733
|
-
}), 304);
|
|
1734
|
-
|
|
1735
|
-
assert.doesNotMatch(joined, /thinking/);
|
|
1736
|
-
assert.doesNotMatch(joined, /Directory: C:\\Users/);
|
|
1737
|
-
assert.doesNotMatch(joined, /import \{ useMemo \}/);
|
|
1738
|
-
});
|
|
1739
|
-
|
|
1740
|
-
test("action event hides PowerShell full-path wrapper and shows friendly label", () => {
|
|
1741
|
-
const raw = `"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -Command 'Get-ChildItem -Force | Select-Object Name,Mode,Length'`;
|
|
1742
|
-
const items = buildTimelineItems(makeCompletedRunWithTool(200, raw));
|
|
1743
|
-
const renderItems = buildStaticRenderItems(items, [200], null, null, null);
|
|
1744
|
-
const snapshot = buildTimelineSnapshot(renderItems, { totalWidth: 80 });
|
|
1745
|
-
const joined = snapshot.rows.map((row) => row.spans.map((s) => s.text).join("")).join("\n");
|
|
1746
|
-
|
|
1747
|
-
assert.match(joined, /List files/, "should show friendly label");
|
|
1748
|
-
assert.doesNotMatch(joined, /Program Files/, "should not expose PowerShell install path");
|
|
1749
|
-
assert.doesNotMatch(joined, /pwsh\.exe/, "should not expose pwsh.exe wrapper");
|
|
1750
|
-
assert.doesNotMatch(joined, /-Command/, "should not expose -Command flag");
|
|
1751
|
-
});
|
|
1752
|
-
|
|
1753
|
-
test("action event hides pwsh.exe -Command wrapper", () => {
|
|
1754
|
-
const items = buildTimelineItems(makeCompletedRunWithTool(201, `pwsh.exe -Command 'git status'`));
|
|
1755
|
-
const renderItems = buildStaticRenderItems(items, [201], null, null, null);
|
|
1756
|
-
const snapshot = buildTimelineSnapshot(renderItems, { totalWidth: 80 });
|
|
1757
|
-
const joined = snapshot.rows.map((row) => row.spans.map((s) => s.text).join("")).join("\n");
|
|
1758
|
-
|
|
1759
|
-
assert.match(joined, /Check git status/);
|
|
1760
|
-
assert.doesNotMatch(joined, /pwsh\.exe/);
|
|
1761
|
-
});
|
|
1762
|
-
|
|
1763
|
-
test("action event hides cmd.exe /c wrapper", () => {
|
|
1764
|
-
const items = buildTimelineItems(makeCompletedRunWithTool(202, `cmd.exe /c "dir /b"`));
|
|
1765
|
-
const renderItems = buildStaticRenderItems(items, [202], null, null, null);
|
|
1766
|
-
const snapshot = buildTimelineSnapshot(renderItems, { totalWidth: 80 });
|
|
1767
|
-
const joined = snapshot.rows.map((row) => row.spans.map((s) => s.text).join("")).join("\n");
|
|
1768
|
-
|
|
1769
|
-
assert.match(joined, /List files/);
|
|
1770
|
-
assert.doesNotMatch(joined, /cmd\.exe/);
|
|
1771
|
-
});
|
|
1772
|
-
|
|
1773
|
-
test("action event hides bash -lc wrapper", () => {
|
|
1774
|
-
const items = buildTimelineItems(makeCompletedRunWithTool(203, `bash -lc 'git diff HEAD~1'`));
|
|
1775
|
-
const renderItems = buildStaticRenderItems(items, [203], null, null, null);
|
|
1776
|
-
const snapshot = buildTimelineSnapshot(renderItems, { totalWidth: 80 });
|
|
1777
|
-
const joined = snapshot.rows.map((row) => row.spans.map((s) => s.text).join("")).join("\n");
|
|
1778
|
-
|
|
1779
|
-
assert.match(joined, /Inspect changes/);
|
|
1780
|
-
assert.doesNotMatch(joined, /bash -lc/);
|
|
1781
|
-
});
|
|
1782
|
-
|
|
1783
|
-
test("action event shows normalized command only when no friendly label exists", () => {
|
|
1784
|
-
const items = buildTimelineItems(makeCompletedRunWithTool(204, `pwsh.exe -Command 'python -m pytest tests/'`));
|
|
1785
|
-
const renderItems = buildStaticRenderItems(items, [204], null, null, null);
|
|
1786
|
-
const snapshot = buildTimelineSnapshot(renderItems, { totalWidth: 80 });
|
|
1787
|
-
const joined = snapshot.rows.map((row) => row.spans.map((s) => s.text).join("")).join("\n");
|
|
1788
|
-
|
|
1789
|
-
assert.match(joined, /python -m pytest/, "should show normalized command as label");
|
|
1790
|
-
assert.doesNotMatch(joined, /pwsh\.exe/, "should not show wrapper");
|
|
1791
|
-
});
|
|
1792
|
-
|
|
1793
|
-
test("action event remains inside the unified assistant turn (no separate Processing card)", () => {
|
|
1794
|
-
const raw = `"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -Command 'Get-ChildItem'`;
|
|
1795
|
-
const items = buildTimelineItems(makeCompletedRunWithTool(205, raw));
|
|
1796
|
-
const renderItems = buildStaticRenderItems(items, [205], null, null, null);
|
|
1797
|
-
const snapshot = buildTimelineSnapshot(renderItems, { totalWidth: 80 });
|
|
1798
|
-
|
|
1799
|
-
// All content should be in a single turn item, not a separate Processing card
|
|
1800
|
-
assert.equal(snapshot.items.length, 1, "should produce exactly one timeline item (the turn)");
|
|
1801
|
-
const joined = snapshot.rows.map((row) => row.spans.map((s) => s.text).join("")).join("\n");
|
|
1802
|
-
assert.match(joined, /action/);
|
|
1803
|
-
assert.doesNotMatch(joined, /Processing/, "should not render a separate Processing card");
|
|
1804
|
-
});
|
|
1805
|
-
|
|
1806
|
-
test("long command is wrapped within the bordered action card", () => {
|
|
1807
|
-
const longCmd = `pwsh.exe -Command 'Write-Host "A very long command that goes on and on and on and would overflow if not wrapped properly within the card border"'`;
|
|
1808
|
-
const items = buildTimelineItems(makeCompletedRunWithTool(206, longCmd));
|
|
1809
|
-
const renderItems = buildStaticRenderItems(items, [206], null, null, null);
|
|
1810
|
-
const totalWidth = 60;
|
|
1811
|
-
const snapshot = buildTimelineSnapshot(renderItems, { totalWidth });
|
|
1812
|
-
|
|
1813
|
-
const actionRows = snapshot.rows.filter((row) =>
|
|
1814
|
-
row.spans.some((s) => s.text.includes("Write-Host")),
|
|
1815
|
-
);
|
|
1816
|
-
assert.ok(actionRows.length >= 1, "long command should render inside the action card");
|
|
1817
|
-
|
|
1818
|
-
for (const row of actionRows) {
|
|
1819
|
-
const actionText = row.spans.map((span) => span.text).join("");
|
|
1820
|
-
assert.doesNotMatch(actionText, /would overflow if not wrapped properly within the card border/);
|
|
1821
|
-
}
|
|
1822
|
-
assert.match(snapshot.rows.map((row) => row.spans.map((span) => span.text).join("")).join("\n"), /╭── action/);
|
|
1823
|
-
});
|
|
1824
|
-
|
|
1825
|
-
// ── Smooth scrolling / render-loop regression tests ──────────────────────────
|
|
1826
|
-
|
|
1827
|
-
test("syncTimelineViewport is stable when followTail is true and totalRows did not grow", () => {
|
|
1828
|
-
// Ensures the Fix B guard condition is sound: when following tail and no new
|
|
1829
|
-
// rows arrive, returning `current` from setViewport bails out of the render.
|
|
1830
|
-
const snapshot = createSnapshot([2, 3]);
|
|
1831
|
-
const following = createFollowTailViewport(snapshot.totalRows);
|
|
1832
|
-
const synced = syncTimelineViewport(following, snapshot);
|
|
1833
|
-
|
|
1834
|
-
assert.strictEqual(synced, following);
|
|
1835
|
-
assert.equal(synced.followTail, true);
|
|
1836
|
-
assert.equal(synced.anchorRow, snapshot.totalRows - 1);
|
|
1837
|
-
assert.equal(synced.unseenItems, 0);
|
|
1838
|
-
assert.equal(synced.unseenRows, 0);
|
|
1839
|
-
});
|
|
1840
|
-
|
|
1841
|
-
test("follow-tail viewport stays anchored when an action updates without row growth", () => {
|
|
1842
|
-
const runningEvents = makeChronologicalTurnEvents(300, {
|
|
1843
|
-
status: "running",
|
|
1844
|
-
durationMs: null,
|
|
1845
|
-
toolActivities: [{
|
|
1846
|
-
id: "tool-1",
|
|
1847
|
-
command: "Get-Content README.md",
|
|
1848
|
-
status: "running",
|
|
1849
|
-
startedAt: 10,
|
|
1850
|
-
completedAt: null,
|
|
1851
|
-
streamSeq: 1,
|
|
1852
|
-
}],
|
|
1853
|
-
streamItems: [{ streamSeq: 1, kind: "action", refId: "tool-1" }],
|
|
1854
|
-
lastStreamSeq: 1,
|
|
1855
|
-
}, "");
|
|
1856
|
-
const completedEvents = makeChronologicalTurnEvents(300, {
|
|
1857
|
-
status: "running",
|
|
1858
|
-
durationMs: null,
|
|
1859
|
-
toolActivities: [{
|
|
1860
|
-
id: "tool-1",
|
|
1861
|
-
command: "Get-Content README.md",
|
|
1862
|
-
status: "completed",
|
|
1863
|
-
startedAt: 10,
|
|
1864
|
-
completedAt: 42,
|
|
1865
|
-
summary: "Read 12 lines",
|
|
1866
|
-
streamSeq: 1,
|
|
1867
|
-
}],
|
|
1868
|
-
streamItems: [{ streamSeq: 1, kind: "action", refId: "tool-1" }],
|
|
1869
|
-
lastStreamSeq: 1,
|
|
1870
|
-
}, "");
|
|
1871
|
-
|
|
1872
|
-
const turnIds = [300];
|
|
1873
|
-
const runningSnapshot = buildTimelineSnapshot(
|
|
1874
|
-
buildActiveRenderItems(buildTimelineItems(runningEvents), turnIds, { kind: "THINKING", turnId: 300 }),
|
|
1875
|
-
{ totalWidth: 80 },
|
|
1876
|
-
);
|
|
1877
|
-
const completedSnapshot = buildTimelineSnapshot(
|
|
1878
|
-
buildActiveRenderItems(buildTimelineItems(completedEvents), turnIds, { kind: "THINKING", turnId: 300 }),
|
|
1879
|
-
{ totalWidth: 80 },
|
|
1880
|
-
);
|
|
1881
|
-
const viewport = createFollowTailViewport(runningSnapshot.totalRows);
|
|
1882
|
-
const synced = syncTimelineViewport(viewport, completedSnapshot);
|
|
1883
|
-
|
|
1884
|
-
assert.equal(completedSnapshot.totalRows, runningSnapshot.totalRows);
|
|
1885
|
-
assert.strictEqual(synced, viewport);
|
|
1886
|
-
assert.equal(synced.followTail, true);
|
|
1887
|
-
assert.equal(synced.anchorRow, runningSnapshot.totalRows - 1);
|
|
1888
|
-
});
|
|
1889
|
-
|
|
1890
|
-
test("selectTimelineRows preserves visible row object references", () => {
|
|
1891
|
-
const snapshot = createSnapshot([4, 4, 4]);
|
|
1892
|
-
const viewport = createFollowTailViewport(snapshot.totalRows);
|
|
1893
|
-
|
|
1894
|
-
const first = selectTimelineRows(snapshot, viewport, 5);
|
|
1895
|
-
const second = selectTimelineRows(snapshot, viewport, 5);
|
|
1896
|
-
|
|
1897
|
-
assert.notStrictEqual(second.visibleRows, first.visibleRows);
|
|
1898
|
-
assert.equal(second.visibleRows.length, first.visibleRows.length);
|
|
1899
|
-
for (let index = 0; index < first.visibleRows.length; index += 1) {
|
|
1900
|
-
assert.strictEqual(second.visibleRows[index], first.visibleRows[index]);
|
|
1901
|
-
}
|
|
1902
|
-
});
|
|
1903
|
-
|
|
1904
|
-
test("buildTimelineSnapshot reuses cached rows for completed entries on repeated calls", () => {
|
|
1905
|
-
// Ensures Fix E is working: a second call at the same width must return
|
|
1906
|
-
// the same inner row array references (cache hits) for completed turns.
|
|
1907
|
-
const completedEvents: TimelineEvent[] = [
|
|
1908
|
-
{
|
|
1909
|
-
id: 100,
|
|
1910
|
-
type: "user",
|
|
1911
|
-
createdAt: 1,
|
|
1912
|
-
prompt: "Cache test prompt",
|
|
1913
|
-
turnId: 99,
|
|
1914
|
-
},
|
|
1915
|
-
{
|
|
1916
|
-
id: 101,
|
|
1917
|
-
type: "run",
|
|
1918
|
-
createdAt: 2,
|
|
1919
|
-
startedAt: 2,
|
|
1920
|
-
durationMs: 100,
|
|
1921
|
-
backendId: "codex-subprocess",
|
|
1922
|
-
backendLabel: "Codexa",
|
|
1923
|
-
runtime: TEST_RUNTIME,
|
|
1924
|
-
prompt: "Cache test prompt",
|
|
1925
|
-
progressEntries: [],
|
|
1926
|
-
status: "completed",
|
|
1927
|
-
summary: "Done",
|
|
1928
|
-
truncatedOutput: false,
|
|
1929
|
-
toolActivities: [],
|
|
1930
|
-
activity: [],
|
|
1931
|
-
touchedFileCount: 0,
|
|
1932
|
-
errorMessage: null,
|
|
1933
|
-
turnId: 99,
|
|
1934
|
-
},
|
|
1935
|
-
{
|
|
1936
|
-
id: 102,
|
|
1937
|
-
type: "assistant",
|
|
1938
|
-
createdAt: 3,
|
|
1939
|
-
content: "Cached answer",
|
|
1940
|
-
contentChunks: [],
|
|
1941
|
-
turnId: 99,
|
|
1942
|
-
},
|
|
1943
|
-
];
|
|
1944
|
-
|
|
1945
|
-
const renderItems = buildStaticRenderItems(
|
|
1946
|
-
buildTimelineItems(completedEvents),
|
|
1947
|
-
[99],
|
|
1948
|
-
null,
|
|
1949
|
-
null,
|
|
1950
|
-
null,
|
|
1951
|
-
);
|
|
1952
|
-
|
|
1953
|
-
const first = buildTimelineSnapshot(renderItems, { totalWidth: 80 });
|
|
1954
|
-
const second = buildTimelineSnapshot(renderItems, { totalWidth: 80 });
|
|
1955
|
-
|
|
1956
|
-
assert.equal(first.totalRows, second.totalRows, "total rows must be identical");
|
|
1957
|
-
|
|
1958
|
-
// The wrapItemRows call adds padding so the outer `rows` array is always
|
|
1959
|
-
// a fresh array. The inner pre-wrap rows are what the cache stores — but
|
|
1960
|
-
// since buildTimelineSnapshot only exposes wrapped rows we verify equality
|
|
1961
|
-
// of row *content* (same spans) as a proxy for cache correctness.
|
|
1962
|
-
for (let i = 0; i < first.items.length; i++) {
|
|
1963
|
-
const a = first.items[i];
|
|
1964
|
-
const b = second.items[i];
|
|
1965
|
-
assert.ok(a && b, `item ${i} must exist in both snapshots`);
|
|
1966
|
-
assert.equal(a.rowCount, b.rowCount, `item ${i} rowCount must match`);
|
|
1967
|
-
}
|
|
1968
|
-
});
|
|
1969
|
-
|
|
1970
|
-
// ─── Scroll-jump prevention ───────────────────────────────────────────────────
|
|
1971
|
-
|
|
1972
|
-
test("syncTimelineViewport with empty snapshot preserves frozen scroll offset", () => {
|
|
1973
|
-
// User scrolled to anchorRow=50 in a 100-row transcript.
|
|
1974
|
-
const snapshot = createSnapshot(Array.from({ length: 100 }, () => 1));
|
|
1975
|
-
const frozen: TimelineViewportState = {
|
|
1976
|
-
anchorRow: 50,
|
|
1977
|
-
followTail: false,
|
|
1978
|
-
unseenItems: 0,
|
|
1979
|
-
unseenRows: 0,
|
|
1980
|
-
frozenSnapshot: snapshot,
|
|
1981
|
-
};
|
|
1982
|
-
const emptySnapshot = createSnapshot([]);
|
|
1983
|
-
|
|
1984
|
-
// Transient empty snapshot must NOT reset position to row 0.
|
|
1985
|
-
const result = syncTimelineViewport(frozen, emptySnapshot);
|
|
1986
|
-
assert.equal(result.followTail, false, "must remain detached");
|
|
1987
|
-
assert.equal(result.anchorRow, 50, "anchor row must be preserved");
|
|
1988
|
-
});
|
|
1989
|
-
|
|
1990
|
-
test("syncTimelineViewport with empty snapshot resets follow-tail viewport to row 0", () => {
|
|
1991
|
-
const snapshot = createSnapshot([10, 10]);
|
|
1992
|
-
const following = createFollowTailViewport(snapshot.totalRows);
|
|
1993
|
-
|
|
1994
|
-
const empty = createSnapshot([]);
|
|
1995
|
-
const result = syncTimelineViewport(following, empty);
|
|
1996
|
-
|
|
1997
|
-
// Follow-tail with empty snapshot is the initial/cold-start state — reset is expected.
|
|
1998
|
-
assert.equal(result.followTail, true);
|
|
1999
|
-
assert.equal(result.anchorRow, 0);
|
|
2000
|
-
});
|
|
2001
|
-
|
|
2002
|
-
test("isNearBottom returns true when anchorRow is within threshold of tail", () => {
|
|
2003
|
-
assert.equal(isNearBottom(97, 100), true, "2 rows from tail → near bottom");
|
|
2004
|
-
assert.equal(isNearBottom(96, 100), true, "3 rows from tail → near bottom (threshold boundary)");
|
|
2005
|
-
assert.equal(isNearBottom(95, 100), false, "4 rows from tail → NOT near bottom");
|
|
2006
|
-
assert.equal(isNearBottom(99, 100), true, "at tail → near bottom");
|
|
2007
|
-
assert.equal(isNearBottom(0, 0), true, "empty content → near bottom");
|
|
2008
|
-
});
|
|
2009
|
-
|
|
2010
|
-
test("isNearBottom detects proximity correctly across different snapshot sizes", () => {
|
|
2011
|
-
// Large transcript — threshold 3 rows from end
|
|
2012
|
-
assert.equal(isNearBottom(97, 100), true, "100-row: 3 rows from tail → near bottom");
|
|
2013
|
-
assert.equal(isNearBottom(95, 100), false, "100-row: 5 rows from tail → NOT near bottom");
|
|
2014
|
-
|
|
2015
|
-
// Small transcript — entire content is within threshold
|
|
2016
|
-
assert.equal(isNearBottom(1, 4), true, "4-row: row 1 within threshold of tail");
|
|
2017
|
-
assert.equal(isNearBottom(0, 4), true, "4-row: row 0 within threshold of tail");
|
|
2018
|
-
|
|
2019
|
-
// Edge cases
|
|
2020
|
-
assert.equal(isNearBottom(0, 0), true, "empty content → near bottom");
|
|
2021
|
-
assert.equal(isNearBottom(99, 100), true, "exactly at tail → near bottom");
|
|
2022
|
-
});
|
|
2023
|
-
|
|
2024
|
-
test("provider-picker style open/close: frozen scroll position survives empty-snapshot transition", () => {
|
|
2025
|
-
// Simulate: user scrolled up, then a panel opens causing a transient snapshot drop,
|
|
2026
|
-
// then panel closes and snapshot restores.
|
|
2027
|
-
const fullSnapshot = createSnapshot(Array.from({ length: 80 }, () => 1));
|
|
2028
|
-
|
|
2029
|
-
// User scrolls to row 40
|
|
2030
|
-
const scrolled = scrollTimelineViewport(
|
|
2031
|
-
createFollowTailViewport(fullSnapshot.totalRows),
|
|
2032
|
-
fullSnapshot,
|
|
2033
|
-
20,
|
|
2034
|
-
-(fullSnapshot.totalRows - 1 - 40), // navigate to anchorRow ~40
|
|
2035
|
-
);
|
|
2036
|
-
assert.equal(scrolled.followTail, false);
|
|
2037
|
-
|
|
2038
|
-
// Panel opens: transient empty snapshot (liveRows excluded)
|
|
2039
|
-
const afterEmpty = syncTimelineViewport(scrolled, createSnapshot([]));
|
|
2040
|
-
assert.equal(afterEmpty.followTail, false, "panel open must not reset scroll");
|
|
2041
|
-
assert.equal(afterEmpty.anchorRow, scrolled.anchorRow, "anchor must be preserved through empty snapshot");
|
|
2042
|
-
|
|
2043
|
-
// Panel closes: snapshot restores
|
|
2044
|
-
const afterRestore = syncTimelineViewport(afterEmpty, fullSnapshot);
|
|
2045
|
-
assert.equal(afterRestore.followTail, false, "scroll must remain detached after restore");
|
|
2046
|
-
});
|
|
2047
|
-
|
|
2048
|
-
test("response completion does not jump to top when user is scrolled mid-transcript", () => {
|
|
2049
|
-
// User is scrolled to row 20 in a 50-row transcript
|
|
2050
|
-
const snapshot50 = createSnapshot(Array.from({ length: 50 }, () => 1));
|
|
2051
|
-
const browsing: TimelineViewportState = {
|
|
2052
|
-
anchorRow: 20,
|
|
2053
|
-
followTail: false,
|
|
2054
|
-
unseenItems: 0,
|
|
2055
|
-
unseenRows: 0,
|
|
2056
|
-
frozenSnapshot: snapshot50,
|
|
2057
|
-
};
|
|
2058
|
-
|
|
2059
|
-
// Response finalizes: content grows to 80 rows
|
|
2060
|
-
const snapshot80 = createSnapshot(Array.from({ length: 80 }, () => 1));
|
|
2061
|
-
const afterFinalize = syncTimelineViewport(browsing, snapshot80);
|
|
2062
|
-
|
|
2063
|
-
assert.equal(afterFinalize.followTail, false, "must not snap to follow-tail on finalize");
|
|
2064
|
-
assert.equal(afterFinalize.anchorRow, 20, "anchor must remain at row 20");
|
|
2065
|
-
// Unseen rows should reflect new content
|
|
2066
|
-
assert.equal(afterFinalize.unseenRows, 30, "unseen rows = 80 - 50");
|
|
2067
|
-
});
|