@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.
Files changed (206) hide show
  1. package/README.md +396 -100
  2. package/bin/codexa.js +62 -144
  3. package/package.json +14 -8
  4. package/src/app.tsx +596 -306
  5. package/src/commands/handler.ts +6 -6
  6. package/src/config/buildInfo.ts +2 -2
  7. package/src/config/layeredConfig.ts +1 -1
  8. package/src/config/persistence.ts +10 -0
  9. package/src/config/runtimeConfig.ts +1 -1
  10. package/src/config/settings.ts +8 -16
  11. package/src/config/trustStore.ts +1 -1
  12. package/src/config/updateCheckCache.ts +19 -1
  13. package/src/core/README.md +52 -0
  14. package/src/core/agent/loop.ts +282 -0
  15. package/src/core/agent/protocol.ts +211 -0
  16. package/src/core/agent/tools.ts +414 -0
  17. package/src/core/{codexExecArgs.ts → codex/codexExecArgs.ts} +2 -2
  18. package/src/core/{codexLaunch.ts → codex/codexLaunch.ts} +3 -3
  19. package/src/core/{codexPrompt.ts → codex/codexPrompt.ts} +3 -3
  20. package/src/core/debug/modelStateDebug.ts +34 -0
  21. package/src/core/executables/antigravityExecutable.ts +48 -0
  22. package/src/core/executables/codexExecutable.ts +1 -0
  23. package/src/core/executables/executableResolver.ts +65 -43
  24. package/src/core/perf/renderDebug.ts +10 -6
  25. package/src/core/process/processValidation.ts +9 -5
  26. package/src/core/providerLauncher/launcher.ts +59 -42
  27. package/src/core/providerLauncher/registry.ts +30 -14
  28. package/src/core/providerLauncher/types.ts +11 -9
  29. package/src/core/providerLauncher/workspaceConfig.ts +41 -26
  30. package/src/core/providerRuntime/anthropic.ts +7 -1
  31. package/src/core/providerRuntime/antigravity.ts +305 -0
  32. package/src/core/providerRuntime/claudeCodeDiscovery.ts +268 -22
  33. package/src/core/providerRuntime/claudeCodeDiscoveryDebug.ts +55 -0
  34. package/src/core/providerRuntime/contextMetadata.ts +12 -24
  35. package/src/core/providerRuntime/local.ts +129 -51
  36. package/src/core/providerRuntime/models.ts +22 -11
  37. package/src/core/providerRuntime/registry.ts +58 -31
  38. package/src/core/providerRuntime/types.ts +19 -14
  39. package/src/core/providers/codexSubprocess.ts +2 -2
  40. package/src/core/providers/types.ts +1 -1
  41. package/src/core/{attachments.ts → shared/attachments.ts} +27 -4
  42. package/src/core/{cleanupFastFail.ts → shared/cleanupFastFail.ts} +1 -1
  43. package/src/core/{hollowResponseFormat.ts → shared/hollowResponseFormat.ts} +1 -1
  44. package/src/core/terminal/clearFrameBoundary.ts +814 -0
  45. package/src/core/terminal/frameLock.ts +109 -0
  46. package/src/core/terminal/inkRenderReset.ts +123 -0
  47. package/src/core/terminal/terminalControl.ts +22 -0
  48. package/src/core/terminal/terminalTitle.ts +16 -102
  49. package/src/core/version/channel.ts +23 -0
  50. package/src/core/version/updateCheck.ts +193 -0
  51. package/src/core/{launchContext.ts → workspace/launchContext.ts} +32 -39
  52. package/src/core/{planStorage.ts → workspace/planStorage.ts} +2 -2
  53. package/src/core/{workspaceGuard.ts → workspace/workspaceGuard.ts} +97 -13
  54. package/src/headless/execRunner.ts +2 -2
  55. package/src/index.tsx +43 -89
  56. package/src/session/appSession.ts +10 -7
  57. package/src/session/chatLifecycle.ts +1 -1
  58. package/src/session/liveRenderScheduler.ts +1 -1
  59. package/src/session/types.ts +3 -2
  60. package/src/ui/ActionRequiredBlock.tsx +5 -5
  61. package/src/ui/ActivityBars.tsx +3 -3
  62. package/src/ui/ActivityIndicator.tsx +6 -6
  63. package/src/ui/AgentBlock.tsx +6 -6
  64. package/src/ui/AnimatedStatusText.tsx +1 -1
  65. package/src/ui/AppShell.tsx +670 -719
  66. package/src/ui/AttachmentImportPanel.tsx +8 -8
  67. package/src/ui/AuthPanel.tsx +20 -20
  68. package/src/ui/BottomComposer.tsx +158 -118
  69. package/src/ui/DashCard.tsx +3 -3
  70. package/src/ui/Markdown.tsx +17 -17
  71. package/src/ui/ModelPickerScreen.tsx +222 -42
  72. package/src/ui/ModelReasoningPicker.tsx +15 -15
  73. package/src/ui/Panel.tsx +3 -3
  74. package/src/ui/PlanActionPicker.tsx +6 -6
  75. package/src/ui/PlanReviewPanel.tsx +9 -9
  76. package/src/ui/ProviderPicker.tsx +735 -321
  77. package/src/ui/RunFooter.tsx +3 -3
  78. package/src/ui/RuntimeStatusBar.tsx +108 -0
  79. package/src/ui/SelectionPanel.tsx +8 -4
  80. package/src/ui/SettingsPanel.tsx +9 -9
  81. package/src/ui/Spinner.tsx +1 -1
  82. package/src/ui/TextEntryPanel.tsx +11 -11
  83. package/src/ui/ThinkingBlock.tsx +8 -8
  84. package/src/ui/Timeline.tsx +1625 -1472
  85. package/src/ui/TopHeader.tsx +437 -293
  86. package/src/ui/TranscriptShell.tsx +322 -0
  87. package/src/ui/TurnGroup.tsx +33 -33
  88. package/src/ui/UpdateAvailableCard.tsx +41 -0
  89. package/src/ui/UpdatePromptPanel.tsx +197 -0
  90. package/src/ui/focus.ts +3 -0
  91. package/src/ui/layout.ts +299 -25
  92. package/src/ui/layoutListWindow.ts +145 -0
  93. package/src/ui/logoVariants.ts +103 -0
  94. package/src/ui/modeDisplay.ts +12 -12
  95. package/src/ui/runtimeDisplay.ts +112 -0
  96. package/src/ui/textLayout.ts +15 -4
  97. package/src/ui/theme.tsx +274 -395
  98. package/src/ui/timelineMeasure.ts +218 -136
  99. package/scripts/audit-codexa-capabilities.mjs +0 -466
  100. package/scripts/gen-build-info.mjs +0 -33
  101. package/scripts/smoke-terminal-bench.mjs +0 -35
  102. package/src/appRenderStability.test.ts +0 -131
  103. package/src/commands/handler.test.ts +0 -655
  104. package/src/config/launchArgs.test.ts +0 -189
  105. package/src/config/layeredConfig.test.ts +0 -143
  106. package/src/config/persistence.test.ts +0 -114
  107. package/src/config/runtimeConfig.test.ts +0 -218
  108. package/src/config/settings.test.ts +0 -155
  109. package/src/config/trustStore.test.ts +0 -29
  110. package/src/core/attachments.test.ts +0 -155
  111. package/src/core/auth/codexAuth.test.ts +0 -68
  112. package/src/core/cleanupFastFail.test.ts +0 -76
  113. package/src/core/codex.ts +0 -124
  114. package/src/core/codexExecArgs.test.ts +0 -195
  115. package/src/core/codexLaunch.test.ts +0 -205
  116. package/src/core/codexPrompt.test.ts +0 -252
  117. package/src/core/executables/codexExecutable.test.ts +0 -212
  118. package/src/core/executables/executableResolver.test.ts +0 -129
  119. package/src/core/executables/geminiExecutable.test.ts +0 -116
  120. package/src/core/executables/pathSanityScan.test.ts +0 -47
  121. package/src/core/githubDiagnostics.test.ts +0 -92
  122. package/src/core/hollowResponseFormat.test.ts +0 -58
  123. package/src/core/launchContext.test.ts +0 -157
  124. package/src/core/models/codexCapabilities.test.ts +0 -45
  125. package/src/core/models/codexModelCapabilities.test.ts +0 -246
  126. package/src/core/models/modelSpecs.test.ts +0 -283
  127. package/src/core/perf/renderDebug.test.ts +0 -230
  128. package/src/core/planStorage.test.ts +0 -143
  129. package/src/core/process/CommandRunner.test.ts +0 -105
  130. package/src/core/projectInstructions.test.ts +0 -50
  131. package/src/core/providerLauncher/launcher.test.ts +0 -238
  132. package/src/core/providerLauncher/registry.test.ts +0 -324
  133. package/src/core/providerLauncher/workspaceConfig.test.ts +0 -638
  134. package/src/core/providerRuntime/anthropic.test.ts +0 -1120
  135. package/src/core/providerRuntime/capabilityProfile.test.ts +0 -311
  136. package/src/core/providerRuntime/contextMetadata.test.ts +0 -468
  137. package/src/core/providerRuntime/gemini.test.ts +0 -437
  138. package/src/core/providerRuntime/lmstudio.test.ts +0 -168
  139. package/src/core/providerRuntime/local.test.ts +0 -787
  140. package/src/core/providerRuntime/registry.test.ts +0 -233
  141. package/src/core/providers/codexJsonStream.test.ts +0 -148
  142. package/src/core/providers/codexSubprocess.test.ts +0 -68
  143. package/src/core/providers/codexTranscript.test.ts +0 -284
  144. package/src/core/terminal/startupClear.test.ts +0 -55
  145. package/src/core/terminal/terminalCapabilities.test.ts +0 -93
  146. package/src/core/terminal/terminalControl.test.ts +0 -75
  147. package/src/core/terminal/terminalSanitize.test.ts +0 -22
  148. package/src/core/terminal/terminalSelection.test.ts +0 -42
  149. package/src/core/terminal/terminalTitle.test.ts +0 -328
  150. package/src/core/updateCheck.test.ts +0 -194
  151. package/src/core/updateCheck.ts +0 -172
  152. package/src/core/workspaceActivity.test.ts +0 -163
  153. package/src/core/workspaceGuard.test.ts +0 -151
  154. package/src/core/workspaceRoot.test.ts +0 -23
  155. package/src/exec.test.ts +0 -13
  156. package/src/headless/execArgs.test.ts +0 -147
  157. package/src/headless/execRunner.test.ts +0 -436
  158. package/src/index.test.tsx +0 -620
  159. package/src/session/appSession.test.ts +0 -897
  160. package/src/session/chatLifecycle.test.ts +0 -64
  161. package/src/session/liveRenderScheduler.test.ts +0 -201
  162. package/src/session/planFlow.test.ts +0 -103
  163. package/src/session/planTranscript.test.ts +0 -65
  164. package/src/session/promptRunSchedule.test.ts +0 -36
  165. package/src/ui/ActivityIndicator.test.tsx +0 -58
  166. package/src/ui/AgentBlock.test.ts +0 -6
  167. package/src/ui/AnimatedStatusText.test.ts +0 -16
  168. package/src/ui/AppShell.test.tsx +0 -1776
  169. package/src/ui/AttachmentImportPanel.test.tsx +0 -204
  170. package/src/ui/BottomComposer.test.ts +0 -674
  171. package/src/ui/CodexLogo.tsx +0 -55
  172. package/src/ui/Markdown.test.ts +0 -157
  173. package/src/ui/ModelPickerProviderScope.test.tsx +0 -411
  174. package/src/ui/ModelPickerScreen.test.tsx +0 -99
  175. package/src/ui/ModelPickerState.test.tsx +0 -151
  176. package/src/ui/ModelReasoningPicker.test.tsx +0 -447
  177. package/src/ui/PlanReviewPanel.test.tsx +0 -267
  178. package/src/ui/PromptCardBorder.test.tsx +0 -161
  179. package/src/ui/ProviderPicker.test.tsx +0 -289
  180. package/src/ui/ProviderShortcut.test.tsx +0 -143
  181. package/src/ui/SettingsPanel.test.tsx +0 -233
  182. package/src/ui/StaticTranscriptItem.tsx +0 -56
  183. package/src/ui/Timeline.test.ts +0 -2067
  184. package/src/ui/TimelineNavigation.test.tsx +0 -201
  185. package/src/ui/TopHeader.test.tsx +0 -254
  186. package/src/ui/TurnGroup.test.tsx +0 -365
  187. package/src/ui/busyStatusAnimation.test.ts +0 -30
  188. package/src/ui/commandNormalize.test.ts +0 -142
  189. package/src/ui/diffRenderer.test.ts +0 -102
  190. package/src/ui/focusFlow.test.tsx +0 -1098
  191. package/src/ui/inputBuffer.test.ts +0 -151
  192. package/src/ui/layout.test.ts +0 -146
  193. package/src/ui/modeDisplay.test.ts +0 -42
  194. package/src/ui/runActivityView.test.ts +0 -89
  195. package/src/ui/runLifecycleView.test.tsx +0 -237
  196. package/src/ui/statusRenderIsolation.test.tsx +0 -654
  197. package/src/ui/terminalAnswerFormat.test.ts +0 -19
  198. package/src/ui/textLayout.test.ts +0 -18
  199. package/src/ui/themeFlow.test.ts +0 -53
  200. package/src/ui/timelineMeasureCache.test.ts +0 -986
  201. /package/src/core/{inputDebug.ts → debug/inputDebug.ts} +0 -0
  202. /package/src/core/{clipboard.ts → shared/clipboard.ts} +0 -0
  203. /package/src/core/{githubDiagnostics.ts → shared/githubDiagnostics.ts} +0 -0
  204. /package/src/core/{projectInstructions.ts → workspace/projectInstructions.ts} +0 -0
  205. /package/src/core/{workspaceActivity.ts → workspace/workspaceActivity.ts} +0 -0
  206. /package/src/core/{workspaceRoot.ts → workspace/workspaceRoot.ts} +0 -0
@@ -1,1472 +1,1625 @@
1
- import React, { memo, useEffect, useMemo, useRef, useState } from "react";
2
- import { Box, Text, useInput, useStdin } from "ink";
3
- import {
4
- getRunPlanText,
5
- type AssistantEvent,
6
- type ErrorEvent,
7
- type RunEvent,
8
- type ShellEvent,
9
- type SystemEvent,
10
- type TimelineEvent,
11
- type UIState,
12
- type UserPromptEvent,
13
- } from "../session/types.js";
14
- import { APP_VERSION } from "../config/settings.js";
15
- import type { CodexAuthState } from "../core/auth/codexAuth.js";
16
- import { getAuthStateLabel } from "../core/auth/codexAuth.js";
17
- import * as renderDebug from "../core/perf/renderDebug.js";
18
- import { getShellWidth, type Layout, type StartupHeaderMode } from "./layout.js";
19
- import type { TimelineRow, TimelineSnapshot, TimelineTone } from "./timelineMeasure.js";
20
- import { buildStableTimelineSnapshot, buildTimelineSnapshot } from "./timelineMeasure.js";
21
- import { resolveTurnRunPhase, type TurnOpacity, type TurnRunPhase } from "./TurnGroup.js";
22
- import { useTheme } from "./theme.js";
23
-
24
- // ─── Types & constants ────────────────────────────────────────────────────────
25
-
26
- interface TimelineProps {
27
- staticEvents: TimelineEvent[];
28
- activeEvents: TimelineEvent[];
29
- layout: Layout;
30
- uiState: UIState;
31
- viewportRows: number;
32
- verboseMode?: boolean;
33
- authState?: CodexAuthState;
34
- workspaceLabel?: string;
35
- workspaceRoot?: string | null;
36
- mouseCapture?: boolean;
37
- onMouseActivity?: () => void;
38
- contentSized?: boolean;
39
- }
40
-
41
- type StandaloneTimelineEvent = SystemEvent | ErrorEvent | ShellEvent;
42
-
43
- interface TurnTimelineItem {
44
- type: "turn";
45
- turnId: number;
46
- turnIndex: number;
47
- user: UserPromptEvent | null;
48
- run: RunEvent | null;
49
- assistant: AssistantEvent | null;
50
- }
51
-
52
- interface EventTimelineItem {
53
- type: "event";
54
- event: StandaloneTimelineEvent;
55
- }
56
-
57
- export type TimelineItem = TurnTimelineItem | EventTimelineItem;
58
-
59
- export interface TurnRenderState {
60
- opacity: TurnOpacity;
61
- question: string | null;
62
- runPhase: TurnRunPhase;
63
- }
64
-
65
- interface TurnRenderTimelineItem {
66
- key: string;
67
- type: "turn";
68
- padded: boolean;
69
- item: TurnTimelineItem;
70
- renderState: TurnRenderState;
71
- }
72
-
73
- interface EventRenderTimelineItem {
74
- key: string;
75
- type: "event";
76
- padded: boolean;
77
- event: StandaloneTimelineEvent;
78
- }
79
-
80
- export interface IntroRenderTimelineItem {
81
- key: string;
82
- type: "intro";
83
- padded: boolean;
84
- intro: {
85
- version: string;
86
- layoutMode: Layout["mode"];
87
- startupHeaderMode?: StartupHeaderMode;
88
- authLabel: string;
89
- workspaceLabel: string;
90
- };
91
- }
92
-
93
- export type RenderTimelineItem = IntroRenderTimelineItem | TurnRenderTimelineItem | EventRenderTimelineItem;
94
-
95
- const WHEEL_SCROLL_STEP = 3;
96
- // Re-enter follow-tail mode when the user scrolls within this many rows of the
97
- // tail, preventing a "stuck just above bottom" state after a near-end wheel scroll.
98
- const NEAR_BOTTOM_THRESHOLD = 3;
99
- const PAGE_UP_KEY_INPUTS = new Set(["\u001b[5~", "\u001b[[5~"]);
100
- const PAGE_DOWN_KEY_INPUTS = new Set(["\u001b[6~", "\u001b[[6~"]);
101
- const HOME_KEY_INPUTS = new Set(["\u001b[H", "\u001b[1~", "\u001bOH"]);
102
- const END_KEY_INPUTS = new Set(["\u001b[F", "\u001b[4~", "\u001bOF"]);
103
- const CTRL_HOME_KEY_INPUTS = new Set(["\u001b[1;5H", "\u001b[H"]);
104
- const CTRL_END_KEY_INPUTS = new Set(["\u001b[1;5F", "\u001b[F"]);
105
- const SGR_WHEEL_EVENT_PATTERN = /\u001b\[<(\d+);(\d+);(\d+)([Mm])/g;
106
- const STABLE_RENDER_ENABLED = process.env.CODEXA_STABLE_RENDER !== "0";
107
-
108
- type TimelineNavigationAction = "pageUp" | "pageDown" | "home" | "end" | "wheelUp" | "wheelDown";
109
-
110
- export interface TimelineViewportState {
111
- anchorRow: number;
112
- followTail: boolean;
113
- unseenItems: number;
114
- unseenRows: number;
115
- frozenSnapshot: TimelineSnapshot | null;
116
- }
117
-
118
- interface FinalizeContinuityOptions {
119
- previousTotalRows: number;
120
- viewportRows: number;
121
- }
122
-
123
- // ─── Viewport helpers ────────────────────────────────────────────────────────
124
-
125
- function isStandaloneEvent(event: TimelineEvent): event is StandaloneTimelineEvent {
126
- return event.type === "system" || event.type === "error" || event.type === "shell";
127
- }
128
-
129
- function getActiveTurnId(uiState: UIState): number | null {
130
- return uiState.kind === "THINKING"
131
- || uiState.kind === "RESPONDING"
132
- || uiState.kind === "ANSWER_VISIBLE"
133
- || uiState.kind === "AWAITING_USER_ACTION"
134
- || uiState.kind === "ERROR"
135
- ? uiState.turnId
136
- : null;
137
- }
138
-
139
- function isBusyUiState(uiState: UIState): boolean {
140
- return uiState.kind === "THINKING"
141
- || uiState.kind === "RESPONDING"
142
- || uiState.kind === "ANSWER_VISIBLE"
143
- || uiState.kind === "SHELL_RUNNING";
144
- }
145
-
146
- function getRunningTurnIds(events: TimelineEvent[]): number[] {
147
- return events
148
- .filter((event): event is RunEvent => event.type === "run" && event.status === "running")
149
- .map((event) => event.turnId);
150
- }
151
-
152
- function getFinalizedTurnIds(events: TimelineEvent[]): number[] {
153
- return events
154
- .filter((event): event is RunEvent => event.type === "run" && event.status !== "running")
155
- .map((event) => event.turnId);
156
- }
157
-
158
- function latestFinalizedRunEndsWithPlan(events: TimelineEvent[]): boolean {
159
- for (let index = events.length - 1; index >= 0; index -= 1) {
160
- const event = events[index];
161
- if (!event || event.type !== "run" || event.status === "running") {
162
- continue;
163
- }
164
-
165
- const lastStreamItem = (event.streamItems ?? [])
166
- .slice()
167
- .sort((left, right) => left.streamSeq - right.streamSeq)
168
- .at(-1);
169
- return lastStreamItem?.kind === "plan"
170
- && event.plan?.status === "completed"
171
- && getRunPlanText(event.plan).trim().length > 0;
172
- }
173
-
174
- return false;
175
- }
176
-
177
- function hasFinalizeTransition(params: {
178
- previousRunningTurnIds: number[];
179
- nextRunningTurnIds: number[];
180
- nextFinalizedTurnIds: number[];
181
- previousBusy: boolean;
182
- nextBusy: boolean;
183
- }): boolean {
184
- if (!params.previousBusy || params.nextBusy) return false;
185
- return params.previousRunningTurnIds.some((turnId) =>
186
- !params.nextRunningTurnIds.includes(turnId)
187
- && params.nextFinalizedTurnIds.includes(turnId)
188
- );
189
- }
190
-
191
- function isHomeInput(input: string): boolean {
192
- return HOME_KEY_INPUTS.has(input);
193
- }
194
-
195
- function isEndInput(input: string): boolean {
196
- return END_KEY_INPUTS.has(input);
197
- }
198
-
199
- function rawIncludesAny(raw: string, inputs: ReadonlySet<string>): boolean {
200
- for (const input of inputs) {
201
- if (raw.includes(input)) {
202
- return true;
203
- }
204
- }
205
- return false;
206
- }
207
-
208
- function clampAnchorRow(anchorRow: number, totalRows: number): number {
209
- if (totalRows <= 0) {
210
- return 0;
211
- }
212
-
213
- return Math.max(0, Math.min(anchorRow, totalRows - 1));
214
- }
215
-
216
- export function isNearBottom(anchorRow: number, totalRows: number): boolean {
217
- return totalRows <= 0 || anchorRow >= totalRows - 1 - NEAR_BOTTOM_THRESHOLD;
218
- }
219
-
220
- function getFirstPageAnchor(totalRows: number, viewportRows: number): number {
221
- if (totalRows <= 0) {
222
- return 0;
223
- }
224
- return Math.min(totalRows - 1, Math.max(0, viewportRows - 1));
225
- }
226
-
227
- // ─── Timeline item builders ───────────────────────────────────────────────────
228
-
229
- export function buildTimelineItems(events: TimelineEvent[]): TimelineItem[] {
230
- const items: TimelineItem[] = [];
231
- const turns = new Map<number, TurnTimelineItem>();
232
- let nextTurnIndex = 1;
233
-
234
- for (const event of events) {
235
- if (isStandaloneEvent(event)) {
236
- items.push({ type: "event", event });
237
- continue;
238
- }
239
-
240
- const turnId = event.turnId;
241
- let turn = turns.get(turnId);
242
- if (!turn) {
243
- turn = {
244
- type: "turn",
245
- turnId,
246
- turnIndex: nextTurnIndex++,
247
- user: null,
248
- run: null,
249
- assistant: null,
250
- };
251
- turns.set(turnId, turn);
252
- items.push(turn);
253
- }
254
-
255
- if (event.type === "user") {
256
- turn.user = event;
257
- } else if (event.type === "run") {
258
- turn.run = event;
259
- } else if (event.type === "assistant") {
260
- turn.assistant = event;
261
- }
262
- }
263
-
264
- return items.filter((item) => item.type === "event" || item.user !== null);
265
- }
266
-
267
- export function resolveTurnOpacity(turnIds: number[], turnId: number, activeTurnId: number | null): TurnOpacity {
268
- if (turnIds.length === 0) return "dim";
269
-
270
- if (activeTurnId === null) {
271
- return turnId === turnIds[turnIds.length - 1] ? "recent" : "dim";
272
- }
273
-
274
- const activeIndex = turnIds.indexOf(activeTurnId);
275
- const currentIndex = turnIds.indexOf(turnId);
276
- if (currentIndex === activeIndex) return "active";
277
- if (currentIndex === activeIndex - 1) return "recent";
278
- return "dim";
279
- }
280
-
281
- export function createFollowTailViewport(totalRows: number): TimelineViewportState {
282
- return {
283
- anchorRow: Math.max(0, totalRows - 1),
284
- followTail: true,
285
- unseenItems: 0,
286
- unseenRows: 0,
287
- frozenSnapshot: null,
288
- };
289
- }
290
-
291
- function createAnchoredViewport(snapshot: TimelineSnapshot, anchorRow: number): TimelineViewportState {
292
- return {
293
- anchorRow: clampAnchorRow(anchorRow, snapshot.totalRows),
294
- followTail: false,
295
- unseenItems: 0,
296
- unseenRows: 0,
297
- frozenSnapshot: snapshot,
298
- };
299
- }
300
-
301
- function findFinalResponseStartRow(snapshot: TimelineSnapshot, previousTotalRows: number, viewportRows: number): number | null {
302
- const searchFloor = Math.max(0, previousTotalRows - Math.max(1, viewportRows));
303
- const responseIndex = snapshot.rows.findIndex((row, index) =>
304
- index >= searchFloor && row.key.includes("-codex-response-")
305
- );
306
- return responseIndex >= 0 ? responseIndex : null;
307
- }
308
-
309
- export function createFinalizeContinuityViewport(
310
- snapshot: TimelineSnapshot,
311
- options: FinalizeContinuityOptions,
312
- ): TimelineViewportState {
313
- if (snapshot.totalRows === 0) {
314
- return createFollowTailViewport(0);
315
- }
316
-
317
- const responseStartRow = findFinalResponseStartRow(
318
- snapshot,
319
- options.previousTotalRows,
320
- options.viewportRows,
321
- );
322
- const answerPreviewRows = Math.min(3, Math.max(1, Math.floor(options.viewportRows / 4)));
323
- const fallbackAnchor = options.previousTotalRows + answerPreviewRows - 1;
324
- const anchorRow = responseStartRow === null
325
- ? fallbackAnchor
326
- : responseStartRow + answerPreviewRows - 1;
327
-
328
- return createAnchoredViewport(snapshot, Math.min(snapshot.totalRows - 1, Math.max(0, anchorRow)));
329
- }
330
-
331
- // ─── Viewport operations ─────────────────────────────────────────────────────
332
-
333
- function getFrozenSnapshot(
334
- viewport: TimelineViewportState,
335
- liveSnapshot: TimelineSnapshot,
336
- ): TimelineSnapshot {
337
- return viewport.followTail || viewport.frozenSnapshot === null
338
- ? liveSnapshot
339
- : viewport.frozenSnapshot;
340
- }
341
-
342
- export function syncTimelineViewport(
343
- viewport: TimelineViewportState,
344
- liveSnapshot: TimelineSnapshot,
345
- options: { finalizeContinuity?: FinalizeContinuityOptions } = {},
346
- ): TimelineViewportState {
347
- if (liveSnapshot.totalRows === 0) {
348
- // Preserve a frozen scroll offset through a transient empty-snapshot moment so
349
- // the user's reading position is not reset to row 0. Only reset when the
350
- // viewport is already in follow-tail mode (there is nothing meaningful to preserve).
351
- return viewport.followTail ? createFollowTailViewport(0) : viewport;
352
- }
353
-
354
- if (viewport.followTail) {
355
- if (options.finalizeContinuity) {
356
- return createFinalizeContinuityViewport(liveSnapshot, options.finalizeContinuity);
357
- }
358
-
359
- const nextAnchor = liveSnapshot.totalRows - 1;
360
- if (
361
- viewport.anchorRow === nextAnchor
362
- && viewport.unseenItems === 0
363
- && viewport.unseenRows === 0
364
- && viewport.frozenSnapshot === null
365
- ) {
366
- return viewport;
367
- }
368
- return createFollowTailViewport(liveSnapshot.totalRows);
369
- }
370
-
371
- const frozenSnapshot = viewport.frozenSnapshot ?? liveSnapshot;
372
- const anchorRow = clampAnchorRow(viewport.anchorRow, frozenSnapshot.totalRows);
373
- const unseenItems = Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount);
374
- const unseenRows = Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows);
375
-
376
- if (
377
- viewport.anchorRow === anchorRow
378
- && viewport.unseenItems === unseenItems
379
- && viewport.unseenRows === unseenRows
380
- && viewport.frozenSnapshot === frozenSnapshot
381
- ) {
382
- return viewport;
383
- }
384
-
385
- return {
386
- anchorRow,
387
- followTail: false,
388
- unseenItems,
389
- unseenRows,
390
- frozenSnapshot,
391
- };
392
- }
393
-
394
- /**
395
- * Returns the item index and row-within-item for the given absolute anchorRow
396
- * inside a snapshot. Used by reflowTimelineViewport to translate a row-based
397
- * anchor into a layout-independent (item, offset) anchor.
398
- */
399
- export function findAnchorItem(
400
- snapshot: TimelineSnapshot,
401
- anchorRow: number,
402
- ): { itemIndex: number; rowWithinItem: number } {
403
- let rowOffset = 0;
404
- for (let i = 0; i < snapshot.items.length; i++) {
405
- const item = snapshot.items[i]!;
406
- if (rowOffset + item.rowCount > anchorRow) {
407
- return { itemIndex: i, rowWithinItem: anchorRow - rowOffset };
408
- }
409
- rowOffset += item.rowCount;
410
- }
411
- // anchorRow is at or beyond the last item – clamp to end
412
- const lastIdx = Math.max(0, snapshot.items.length - 1);
413
- const lastItem = snapshot.items[lastIdx];
414
- return {
415
- itemIndex: lastIdx,
416
- rowWithinItem: lastItem ? Math.max(0, lastItem.rowCount - 1) : 0,
417
- };
418
- }
419
-
420
- /**
421
- * Rebuilds the frozen viewport snapshot after a terminal width change.
422
- *
423
- * When the terminal is resized (width changes), all snapshot memos are
424
- * rebuilt at the new snapshotWidth, so liveSnapshot already contains
425
- * correctly reflowed rows. However syncTimelineViewport preserves the
426
- * old frozenSnapshot (with old-width rows), causing visual corruption.
427
- *
428
- * This function replaces the stale frozen snapshot with a new one built
429
- * from the same items as before (liveSnapshot.items[0..frozenItemCount]),
430
- * now correctly wrapped at the new width. The anchorRow is translated
431
- * from the old layout via an item-level anchor so the user's reading
432
- * position is preserved across reflow.
433
- */
434
- export function reflowTimelineViewport(
435
- viewport: TimelineViewportState,
436
- liveSnapshot: TimelineSnapshot,
437
- ): TimelineViewportState {
438
- if (liveSnapshot.totalRows === 0) {
439
- return createFollowTailViewport(0);
440
- }
441
-
442
- if (viewport.followTail) {
443
- return createFollowTailViewport(liveSnapshot.totalRows);
444
- }
445
-
446
- const oldFrozen = viewport.frozenSnapshot ?? liveSnapshot;
447
-
448
- // Translate anchorRow stable (item, rowWithinItem) anchor
449
- const clampedAnchor = clampAnchorRow(viewport.anchorRow, oldFrozen.totalRows);
450
- const { itemIndex: anchorItemIdx, rowWithinItem: anchorRowWithinItem } =
451
- findAnchorItem(oldFrozen, clampedAnchor);
452
-
453
- // Grab the same items from liveSnapshot (already reflowed at new width)
454
- const frozenItemCount = oldFrozen.itemCount;
455
- const newFrozenItems = liveSnapshot.items.slice(0, frozenItemCount);
456
-
457
- if (newFrozenItems.length === 0) {
458
- return createFollowTailViewport(liveSnapshot.totalRows);
459
- }
460
-
461
- // Assemble a new frozen snapshot from the reflowed items
462
- const newFrozenRows = newFrozenItems.flatMap((item) => item.rows);
463
- const newFrozenSnapshot: TimelineSnapshot = {
464
- items: newFrozenItems,
465
- rows: newFrozenRows,
466
- totalRows: newFrozenRows.length,
467
- itemCount: frozenItemCount,
468
- };
469
-
470
- // Reconstruct anchorRow in the new layout
471
- let newAnchorRow = 0;
472
- for (let i = 0; i < anchorItemIdx; i++) {
473
- newAnchorRow += newFrozenItems[i]!.rowCount;
474
- }
475
- const targetItem = newFrozenItems[anchorItemIdx];
476
- if (targetItem) {
477
- newAnchorRow += Math.min(anchorRowWithinItem, targetItem.rowCount - 1);
478
- } else {
479
- // Anchor item is beyond the new frozen range (unseen) – clamp to end
480
- newAnchorRow = Math.max(0, newFrozenSnapshot.totalRows - 1);
481
- }
482
- newAnchorRow = clampAnchorRow(newAnchorRow, newFrozenSnapshot.totalRows);
483
-
484
- const unseenItems = Math.max(0, liveSnapshot.itemCount - frozenItemCount);
485
- const unseenRows = Math.max(0, liveSnapshot.totalRows - newFrozenSnapshot.totalRows);
486
-
487
- return {
488
- anchorRow: newAnchorRow,
489
- followTail: false,
490
- unseenItems,
491
- unseenRows,
492
- frozenSnapshot: newFrozenSnapshot,
493
- };
494
- }
495
-
496
- export function pageUpTimelineViewport(
497
- viewport: TimelineViewportState,
498
- liveSnapshot: TimelineSnapshot,
499
- viewportRows: number,
500
- ): TimelineViewportState {
501
- if (liveSnapshot.totalRows === 0) {
502
- return createFollowTailViewport(0);
503
- }
504
-
505
- const frozenSnapshot = getFrozenSnapshot(viewport, liveSnapshot);
506
- const tailRow = Math.max(0, frozenSnapshot.totalRows - 1);
507
- const currentAnchor = viewport.followTail
508
- ? tailRow
509
- : clampAnchorRow(viewport.anchorRow, frozenSnapshot.totalRows);
510
- const nextAnchor = Math.max(getFirstPageAnchor(frozenSnapshot.totalRows, viewportRows), currentAnchor - Math.max(1, viewportRows));
511
-
512
- return {
513
- anchorRow: nextAnchor,
514
- followTail: false,
515
- unseenItems: Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount),
516
- unseenRows: Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows),
517
- frozenSnapshot,
518
- };
519
- }
520
-
521
- export function pageDownTimelineViewport(
522
- viewport: TimelineViewportState,
523
- liveSnapshot: TimelineSnapshot,
524
- viewportRows: number,
525
- ): TimelineViewportState {
526
- if (liveSnapshot.totalRows === 0 || viewport.followTail) {
527
- return viewport;
528
- }
529
-
530
- const frozenSnapshot = viewport.frozenSnapshot ?? liveSnapshot;
531
- const tailRow = Math.max(0, frozenSnapshot.totalRows - 1);
532
- const currentAnchor = clampAnchorRow(viewport.anchorRow, frozenSnapshot.totalRows);
533
- if (currentAnchor >= tailRow) {
534
- return createFollowTailViewport(liveSnapshot.totalRows);
535
- }
536
-
537
- const nextAnchor = Math.min(tailRow, currentAnchor + Math.max(1, viewportRows));
538
- if (nextAnchor >= tailRow) {
539
- return createFollowTailViewport(liveSnapshot.totalRows);
540
- }
541
-
542
- return {
543
- anchorRow: nextAnchor,
544
- followTail: false,
545
- unseenItems: Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount),
546
- unseenRows: Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows),
547
- frozenSnapshot,
548
- };
549
- }
550
-
551
- export function stepUpTimelineViewport(
552
- viewport: TimelineViewportState,
553
- liveSnapshot: TimelineSnapshot,
554
- viewportRows: number,
555
- ): TimelineViewportState {
556
- if (liveSnapshot.totalRows === 0) {
557
- return createFollowTailViewport(0);
558
- }
559
-
560
- const frozenSnapshot = getFrozenSnapshot(viewport, liveSnapshot);
561
- const tailRow = Math.max(0, frozenSnapshot.totalRows - 1);
562
- const currentAnchor = viewport.followTail
563
- ? tailRow
564
- : clampAnchorRow(viewport.anchorRow, frozenSnapshot.totalRows);
565
- const floor = getFirstPageAnchor(frozenSnapshot.totalRows, viewportRows);
566
-
567
- return {
568
- anchorRow: Math.max(floor, currentAnchor - 1),
569
- followTail: false,
570
- unseenItems: Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount),
571
- unseenRows: Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows),
572
- frozenSnapshot,
573
- };
574
- }
575
-
576
- export function stepDownTimelineViewport(
577
- viewport: TimelineViewportState,
578
- liveSnapshot: TimelineSnapshot,
579
- _viewportRows?: number,
580
- ): TimelineViewportState {
581
- if (liveSnapshot.totalRows === 0 || viewport.followTail) {
582
- return viewport;
583
- }
584
-
585
- const frozenSnapshot = viewport.frozenSnapshot ?? liveSnapshot;
586
- const tailRow = Math.max(0, frozenSnapshot.totalRows - 1);
587
- const currentAnchor = clampAnchorRow(viewport.anchorRow, frozenSnapshot.totalRows);
588
- if (currentAnchor >= tailRow) {
589
- return createFollowTailViewport(liveSnapshot.totalRows);
590
- }
591
-
592
- const nextAnchor = Math.min(tailRow, currentAnchor + 1);
593
- if (nextAnchor >= tailRow) {
594
- return createFollowTailViewport(liveSnapshot.totalRows);
595
- }
596
-
597
- return {
598
- anchorRow: nextAnchor,
599
- followTail: false,
600
- unseenItems: Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount),
601
- unseenRows: Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows),
602
- frozenSnapshot,
603
- };
604
- }
605
-
606
- export function scrollTimelineViewport(
607
- viewport: TimelineViewportState,
608
- liveSnapshot: TimelineSnapshot,
609
- viewportRows: number,
610
- deltaRows: number,
611
- ): TimelineViewportState {
612
- if (liveSnapshot.totalRows === 0) {
613
- return createFollowTailViewport(0);
614
- }
615
- if (deltaRows === 0) {
616
- return viewport;
617
- }
618
-
619
- const frozenSnapshot = getFrozenSnapshot(viewport, liveSnapshot);
620
- const tailRow = Math.max(0, frozenSnapshot.totalRows - 1);
621
-
622
- if (deltaRows > 0 && viewport.followTail) {
623
- return viewport;
624
- }
625
-
626
- const currentAnchor = viewport.followTail
627
- ? tailRow
628
- : clampAnchorRow(viewport.anchorRow, frozenSnapshot.totalRows);
629
-
630
- const floor = getFirstPageAnchor(frozenSnapshot.totalRows, viewportRows);
631
-
632
- let nextAnchor = currentAnchor + deltaRows;
633
-
634
- if (nextAnchor >= tailRow) {
635
- return createFollowTailViewport(liveSnapshot.totalRows);
636
- }
637
-
638
- if (nextAnchor < floor) {
639
- nextAnchor = floor;
640
- }
641
-
642
- return {
643
- anchorRow: nextAnchor,
644
- followTail: false,
645
- unseenItems: Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount),
646
- unseenRows: Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows),
647
- frozenSnapshot,
648
- };
649
- }
650
-
651
- export function homeTimelineViewport(
652
- viewport: TimelineViewportState,
653
- liveSnapshot: TimelineSnapshot,
654
- viewportRows: number,
655
- ): TimelineViewportState {
656
- if (liveSnapshot.totalRows === 0) {
657
- return createFollowTailViewport(0);
658
- }
659
-
660
- const frozenSnapshot = getFrozenSnapshot(viewport, liveSnapshot);
661
- return {
662
- anchorRow: getFirstPageAnchor(frozenSnapshot.totalRows, viewportRows),
663
- followTail: false,
664
- unseenItems: Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount),
665
- unseenRows: Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows),
666
- frozenSnapshot,
667
- };
668
- }
669
-
670
- export function endTimelineViewport(totalRows: number): TimelineViewportState {
671
- return createFollowTailViewport(totalRows);
672
- }
673
-
674
- export function parseWheelScrollDirections(raw: string): Array<"up" | "down"> {
675
- const directions: Array<"up" | "down"> = [];
676
-
677
- for (const match of raw.matchAll(SGR_WHEEL_EVENT_PATTERN)) {
678
- const code = Number.parseInt(match[1] ?? "", 10);
679
- const terminator = match[4];
680
- if (terminator !== "M" || Number.isNaN(code) || (code & 64) !== 64) {
681
- continue;
682
- }
683
-
684
- directions.push((code & 1) === 0 ? "up" : "down");
685
- }
686
-
687
- return directions;
688
- }
689
-
690
- export function parseTimelineNavigationInput(raw: string): TimelineNavigationAction[] {
691
- const actions: TimelineNavigationAction[] = [];
692
-
693
- if (rawIncludesAny(raw, PAGE_UP_KEY_INPUTS)) {
694
- actions.push("pageUp");
695
- }
696
-
697
- if (rawIncludesAny(raw, PAGE_DOWN_KEY_INPUTS)) {
698
- actions.push("pageDown");
699
- }
700
-
701
- if (isHomeInput(raw) || rawIncludesAny(raw, CTRL_HOME_KEY_INPUTS)) {
702
- actions.push("home");
703
- }
704
-
705
- if (isEndInput(raw) || rawIncludesAny(raw, CTRL_END_KEY_INPUTS)) {
706
- actions.push("end");
707
- }
708
-
709
- for (const direction of parseWheelScrollDirections(raw)) {
710
- actions.push(direction === "up" ? "wheelUp" : "wheelDown");
711
- }
712
-
713
- return actions;
714
- }
715
-
716
- // ─── Row selection & render items ────────────────────────────────────────────
717
-
718
- export function selectTimelineRows(
719
- liveSnapshot: TimelineSnapshot,
720
- viewport: TimelineViewportState,
721
- viewportRows: number,
722
- ): {
723
- sourceSnapshot: TimelineSnapshot;
724
- visibleRows: TimelineRow[];
725
- window: {
726
- startRow: number;
727
- endRow: number;
728
- anchorRow: number;
729
- };
730
- } {
731
- const sourceSnapshot = viewport.followTail || viewport.frozenSnapshot === null
732
- ? liveSnapshot
733
- : viewport.frozenSnapshot;
734
- const safeViewportRows = Math.max(1, viewportRows);
735
-
736
- if (sourceSnapshot.totalRows === 0) {
737
- return {
738
- sourceSnapshot,
739
- visibleRows: [],
740
- window: { startRow: 0, endRow: 0, anchorRow: 0 },
741
- };
742
- }
743
-
744
- const anchorRow = viewport.followTail
745
- ? sourceSnapshot.totalRows - 1
746
- : clampAnchorRow(viewport.anchorRow, sourceSnapshot.totalRows);
747
- const endRow = Math.max(1, anchorRow + 1);
748
- const startRow = Math.max(0, endRow - safeViewportRows);
749
-
750
- const visibleRows = sourceSnapshot.rows.slice(startRow, endRow);
751
-
752
- // Aggressive fallback: if slicing returned nothing but we have rows,
753
- // return at least the last row to avoid a blank screen.
754
- if (visibleRows.length === 0 && sourceSnapshot.totalRows > 0) {
755
- const fallbackRow = sourceSnapshot.rows[sourceSnapshot.totalRows - 1]!;
756
- return {
757
- sourceSnapshot,
758
- visibleRows: [fallbackRow],
759
- window: {
760
- startRow: sourceSnapshot.totalRows - 1,
761
- endRow: sourceSnapshot.totalRows,
762
- anchorRow: sourceSnapshot.totalRows - 1,
763
- },
764
- };
765
- }
766
-
767
- return {
768
- sourceSnapshot,
769
- visibleRows,
770
- window: {
771
- startRow,
772
- endRow,
773
- anchorRow,
774
- },
775
- };
776
- }
777
-
778
- export function buildStaticRenderItems(
779
- items: TimelineItem[],
780
- turnIds: number[],
781
- activeTurnId: number | null,
782
- questionTurnId: number | null,
783
- question: string | null,
784
- ): RenderTimelineItem[] {
785
- return items.map((item) => {
786
- if (item.type === "event") {
787
- return {
788
- key: `event-${item.event.id}`,
789
- type: "event",
790
- padded: false,
791
- event: item.event,
792
- };
793
- }
794
-
795
- return {
796
- key: `turn-${item.turnId}`,
797
- type: "turn",
798
- padded: false,
799
- item,
800
- renderState: {
801
- opacity: resolveTurnOpacity(turnIds, item.turnId, activeTurnId),
802
- question: questionTurnId === item.turnId ? question : null,
803
- runPhase: resolveTurnRunPhase(item.run, item.assistant, { kind: "IDLE" }, item.turnId),
804
- },
805
- };
806
- });
807
- }
808
-
809
- export function buildActiveRenderItems(
810
- items: TimelineItem[],
811
- turnIds: number[],
812
- uiState: UIState,
813
- ): RenderTimelineItem[] {
814
- const activeTurnId = getActiveTurnId(uiState);
815
- const questionTurnId = uiState.kind === "AWAITING_USER_ACTION" ? uiState.turnId : null;
816
- const question = uiState.kind === "AWAITING_USER_ACTION" ? uiState.question : null;
817
-
818
- return items.map((item) => {
819
- if (item.type === "event") {
820
- return {
821
- key: `event-${item.event.id}`,
822
- type: "event",
823
- padded: true,
824
- event: item.event,
825
- };
826
- }
827
-
828
- return {
829
- key: `turn-${item.turnId}`,
830
- type: "turn",
831
- padded: false,
832
- item,
833
- renderState: {
834
- opacity: resolveTurnOpacity(turnIds, item.turnId, activeTurnId),
835
- question: questionTurnId === item.turnId ? question : null,
836
- runPhase: resolveTurnRunPhase(item.run, item.assistant, uiState, item.turnId),
837
- },
838
- };
839
- });
840
- }
841
-
842
- function formatAuthLabel(authState: CodexAuthState): string {
843
- const raw = getAuthStateLabel(authState);
844
- return raw.length > 0 ? raw[0]!.toUpperCase() + raw.slice(1) : raw;
845
- }
846
-
847
- export function buildIntroRenderItem(params: {
848
- authState: CodexAuthState;
849
- workspaceLabel: string;
850
- layout: Layout;
851
- startupHeaderMode?: StartupHeaderMode;
852
- }): IntroRenderTimelineItem {
853
- return {
854
- key: "codexa-intro",
855
- type: "intro",
856
- padded: true,
857
- intro: {
858
- version: APP_VERSION,
859
- layoutMode: params.layout.mode,
860
- startupHeaderMode: params.startupHeaderMode,
861
- authLabel: formatAuthLabel(params.authState),
862
- workspaceLabel: params.workspaceLabel,
863
- },
864
- };
865
- }
866
-
867
- // ─── Row rendering ────────────────────────────────────────────────────────────
868
-
869
- function getToneColor(theme: ReturnType<typeof useTheme>, tone: TimelineTone | undefined): string | undefined {
870
- switch (tone) {
871
- case "text": return theme.TEXT;
872
- case "dim": return theme.DIM;
873
- case "muted": return theme.MUTED;
874
- case "accent": return theme.ACCENT;
875
- case "info": return theme.INFO;
876
- case "error": return theme.ERROR;
877
- case "warning": return theme.WARNING;
878
- case "success": return theme.SUCCESS;
879
- case "borderSubtle": return theme.BORDER_SUBTLE;
880
- case "borderActive": return theme.BORDER_ACTIVE;
881
- case "panel": return theme.PANEL;
882
- case "star": return theme.STAR;
883
- default: return undefined;
884
- }
885
- }
886
-
887
- export const TimelineRowView = memo(function TimelineRowView({ row }: { row: TimelineRow }) {
888
- const isActionRow = row.key.includes("-action-");
889
- renderDebug.useRenderDebug("TimelineRow", {
890
- rowKey: row.key,
891
- row,
892
- });
893
- if (isActionRow) {
894
- renderDebug.traceFlickerEvent("timelineRowRender", {
895
- rowKey: row.key,
896
- spanToken: row.spans.map((span) => span.text).join("|"),
897
- });
898
- }
899
-
900
- useEffect(() => {
901
- if (!isActionRow) return;
902
- renderDebug.traceFlickerEvent("timelineRowMount", { rowKey: row.key });
903
- renderDebug.traceLifecycleEvent("ActionRow", "mount", { rowKey: row.key });
904
- renderDebug.traceLifecycleEvent("ActionBlock", "mount", { rowKey: row.key });
905
- return () => {
906
- renderDebug.traceFlickerEvent("timelineRowUnmount", { rowKey: row.key });
907
- renderDebug.traceLifecycleEvent("ActionRow", "unmount", { rowKey: row.key });
908
- renderDebug.traceLifecycleEvent("ActionBlock", "unmount", { rowKey: row.key });
909
- };
910
- }, [isActionRow, row.key]);
911
-
912
- const theme = useTheme();
913
-
914
- return (
915
- <Box width="100%" overflow="hidden">
916
- <Text>
917
- {row.spans.map((span, index) => (
918
- <Text
919
- key={index}
920
- color={getToneColor(theme, span.tone)}
921
- backgroundColor={getToneColor(theme, span.backgroundTone)}
922
- bold={span.bold}
923
- >
924
- {span.text}
925
- </Text>
926
- ))}
927
- </Text>
928
- </Box>
929
- );
930
- }, (prev, next) => prev.row === next.row);
931
-
932
- function rowArraysEqual(left: TimelineRow[], right: TimelineRow[]): boolean {
933
- if (left === right) return true;
934
- if (left.length !== right.length) return false;
935
- for (let index = 0; index < left.length; index += 1) {
936
- if (left[index] !== right[index]) return false;
937
- }
938
- return true;
939
- }
940
-
941
- const TimelineRowsView = memo(function TimelineRowsView({ rows }: { rows: TimelineRow[] }) {
942
- return (
943
- <>
944
- {rows.map((row) => (
945
- <TimelineRowView key={row.key} row={row} />
946
- ))}
947
- </>
948
- );
949
- }, (prev, next) => rowArraysEqual(prev.rows, next.rows));
950
-
951
- const JumpToBottomBar = memo(function JumpToBottomBar({
952
- unseenItems,
953
- mouseCapture,
954
- }: { unseenItems: number; mouseCapture: boolean }) {
955
- const theme = useTheme();
956
- const label = unseenItems > 0
957
- ? `↑ Scrolled up · ${unseenItems} new item${unseenItems === 1 ? "" : "s"} below`
958
- : "↑ Scrolled up";
959
- const hint = mouseCapture ? "wheel↓/End to bottom" : "PgDn/End to bottom";
960
- return (
961
- <Box width="100%" paddingX={1}>
962
- <Text color={theme.INFO}>{label}</Text>
963
- <Text color={theme.DIM}>{` · ${hint}`}</Text>
964
- </Box>
965
- );
966
- });
967
-
968
- // ─── Component ────────────────────────────────────────────────────────────────
969
-
970
- export const Timeline = memo(function Timeline({
971
- staticEvents,
972
- activeEvents,
973
- layout,
974
- uiState,
975
- viewportRows,
976
- verboseMode = false,
977
- authState = "checking",
978
- workspaceLabel = "",
979
- workspaceRoot = null,
980
- mouseCapture = false,
981
- onMouseActivity,
982
- contentSized = false,
983
- }: TimelineProps) {
984
- renderDebug.useRenderDebug("Timeline", {
985
- staticEvents,
986
- activeEvents,
987
- staticEventsLength: staticEvents.length,
988
- activeEventsLength: activeEvents.length,
989
- cols: layout.cols,
990
- rows: layout.rows,
991
- mode: layout.mode,
992
- uiStateKind: uiState.kind,
993
- viewportRows,
994
- verboseMode,
995
- authState,
996
- workspaceLabel,
997
- workspaceRoot,
998
- });
999
- renderDebug.useLifecycleDebug("Timeline", {
1000
- cols: layout.cols,
1001
- rows: layout.rows,
1002
- mode: layout.mode,
1003
- viewportRows,
1004
- });
1005
- renderDebug.traceLayoutValidity("Timeline", {
1006
- cols: layout.cols,
1007
- rows: layout.rows,
1008
- viewportRows,
1009
- });
1010
- renderDebug.useFlickerDebug("timelineRender", {
1011
- staticEvents,
1012
- activeEvents,
1013
- staticEventsLength: staticEvents.length,
1014
- activeEventsLength: activeEvents.length,
1015
- cols: layout.cols,
1016
- rows: layout.rows,
1017
- mode: layout.mode,
1018
- uiStateKind: uiState.kind,
1019
- viewportRows,
1020
- verboseMode,
1021
- authState,
1022
- workspaceLabel,
1023
- });
1024
- renderDebug.useRenderDebug("Transcript", {
1025
- staticEvents,
1026
- activeEvents,
1027
- staticEventsLength: staticEvents.length,
1028
- activeEventsLength: activeEvents.length,
1029
- cols: layout.cols,
1030
- rows: layout.rows,
1031
- mode: layout.mode,
1032
- uiStateKind: uiState.kind,
1033
- viewportRows,
1034
- verboseMode,
1035
- workspaceRoot,
1036
- });
1037
-
1038
- const staticItems = useMemo(() => buildTimelineItems(staticEvents), [staticEvents]);
1039
- const activeItems = useMemo(() => buildTimelineItems(activeEvents), [activeEvents]);
1040
- const activeTurnId = getActiveTurnId(uiState);
1041
- const runningTurnIds = useMemo(() => getRunningTurnIds(activeEvents), [activeEvents]);
1042
- const finalizedTurnIds = useMemo(() => getFinalizedTurnIds(staticEvents), [staticEvents]);
1043
- const finalizedPlanAtTail = useMemo(() => latestFinalizedRunEndsWithPlan(staticEvents), [staticEvents]);
1044
- const finalizeTransitionRef = useRef<{
1045
- runningTurnIds: number[];
1046
- finalizedTurnIds: number[];
1047
- busy: boolean;
1048
- }>({
1049
- runningTurnIds,
1050
- finalizedTurnIds,
1051
- busy: isBusyUiState(uiState),
1052
- });
1053
- const finalizeTransition = hasFinalizeTransition({
1054
- previousRunningTurnIds: finalizeTransitionRef.current.runningTurnIds,
1055
- nextRunningTurnIds: runningTurnIds,
1056
- nextFinalizedTurnIds: finalizedTurnIds,
1057
- previousBusy: finalizeTransitionRef.current.busy,
1058
- nextBusy: isBusyUiState(uiState),
1059
- });
1060
- const questionTurnId = uiState.kind === "AWAITING_USER_ACTION" ? uiState.turnId : null;
1061
- const question = uiState.kind === "AWAITING_USER_ACTION" ? uiState.question : null;
1062
- const staticTurnIds = useMemo(
1063
- () => staticItems.filter((item): item is TurnTimelineItem => item.type === "turn").map((item) => item.turnId),
1064
- [staticItems],
1065
- );
1066
- const activeTurnIds = useMemo(
1067
- () => activeItems.filter((item): item is TurnTimelineItem => item.type === "turn").map((item) => item.turnId),
1068
- [activeItems],
1069
- );
1070
- const allTurnIds = useMemo(
1071
- () => [...staticTurnIds, ...activeTurnIds],
1072
- [activeTurnIds, staticTurnIds],
1073
- );
1074
- const staticRenderItems = useMemo(
1075
- () => buildStaticRenderItems(staticItems, allTurnIds, activeTurnId, questionTurnId, question),
1076
- [activeTurnId, allTurnIds, question, questionTurnId, staticItems],
1077
- );
1078
- const activeRenderItems = useMemo(
1079
- () => buildActiveRenderItems(activeItems, allTurnIds, uiState),
1080
- [activeItems, allTurnIds, uiState],
1081
- );
1082
- // ── Split snapshot building ──────────────────────────────────────────────
1083
- // During streaming, activeEvents change every frame but staticEvents stay
1084
- // the same. We further split the active items into "stable" (user prompt,
1085
- // run header — don't change during streaming) and "streaming" (assistant
1086
- // content — changes every frame). This gives us three cached tiers so only
1087
- // the streaming assistant item is rebuilt each frame.
1088
- const snapshotWidth = getShellWidth(layout.cols);
1089
- const staticSnapshot = useMemo(
1090
- () => buildTimelineSnapshot(staticRenderItems, { totalWidth: snapshotWidth, verboseMode, debugLabel: "static", workspaceRoot }),
1091
- [snapshotWidth, staticRenderItems, verboseMode, workspaceRoot],
1092
- );
1093
- // Partition active items: non-assistant items are stable during streaming
1094
- const isStreaming = uiState.kind === "RESPONDING";
1095
- const activeStableItems = useMemo(
1096
- () => isStreaming
1097
- ? activeRenderItems.filter((item) => !(item.type === "turn" && item.item.assistant))
1098
- : [],
1099
- [activeRenderItems, isStreaming],
1100
- );
1101
- const activeStreamingItems = useMemo(
1102
- () => isStreaming
1103
- ? activeRenderItems.filter((item) => item.type === "turn" && item.item.assistant)
1104
- : activeRenderItems,
1105
- [activeRenderItems, isStreaming],
1106
- );
1107
- const activeStableSnapshot = useMemo(
1108
- () => STABLE_RENDER_ENABLED
1109
- ? { items: [], rows: [], totalRows: 0, itemCount: 0 }
1110
- : activeStableItems.length > 0
1111
- ? buildTimelineSnapshot(activeStableItems, { totalWidth: snapshotWidth, verboseMode, debugLabel: "active-stable", workspaceRoot })
1112
- : { items: [], rows: [], totalRows: 0, itemCount: 0 },
1113
- [snapshotWidth, activeStableItems, verboseMode, workspaceRoot],
1114
- );
1115
- const activeStreamingSnapshot = useMemo(
1116
- () => STABLE_RENDER_ENABLED
1117
- ? { items: [], rows: [], totalRows: 0, itemCount: 0 }
1118
- : buildTimelineSnapshot(activeStreamingItems, { totalWidth: snapshotWidth, verboseMode, debugLabel: "active-streaming", workspaceRoot }),
1119
- [snapshotWidth, activeStreamingItems, verboseMode, workspaceRoot],
1120
- );
1121
- const stableActiveSnapshot = useMemo(
1122
- () => STABLE_RENDER_ENABLED
1123
- ? buildStableTimelineSnapshot(activeRenderItems, { totalWidth: snapshotWidth, verboseMode, debugLabel: "active-stable-render", workspaceRoot })
1124
- : null,
1125
- [snapshotWidth, activeRenderItems, verboseMode, workspaceRoot],
1126
- );
1127
- const liveSnapshot = useMemo(
1128
- () => {
1129
- if (stableActiveSnapshot) {
1130
- return {
1131
- items: [...staticSnapshot.items, ...stableActiveSnapshot.snapshot.items],
1132
- rows: [...staticSnapshot.rows, ...stableActiveSnapshot.snapshot.rows],
1133
- totalRows: staticSnapshot.totalRows + stableActiveSnapshot.snapshot.totalRows,
1134
- itemCount: staticSnapshot.itemCount + stableActiveSnapshot.snapshot.itemCount,
1135
- };
1136
- }
1137
-
1138
- return {
1139
- items: [...staticSnapshot.items, ...activeStableSnapshot.items, ...activeStreamingSnapshot.items],
1140
- rows: [...staticSnapshot.rows, ...activeStableSnapshot.rows, ...activeStreamingSnapshot.rows],
1141
- totalRows: staticSnapshot.totalRows + activeStableSnapshot.totalRows + activeStreamingSnapshot.totalRows,
1142
- itemCount: staticSnapshot.itemCount + activeStableSnapshot.itemCount + activeStreamingSnapshot.itemCount,
1143
- };
1144
- },
1145
- [staticSnapshot, stableActiveSnapshot, activeStableSnapshot, activeStreamingSnapshot],
1146
- );
1147
-
1148
- const lastNonEmptySnapshotRef = useRef<TimelineSnapshot>(liveSnapshot);
1149
- if (liveSnapshot.totalRows > 0) {
1150
- lastNonEmptySnapshotRef.current = liveSnapshot;
1151
- }
1152
-
1153
- const isBusy = uiState.kind === "THINKING"
1154
- || uiState.kind === "RESPONDING"
1155
- || uiState.kind === "ANSWER_VISIBLE"
1156
- || uiState.kind === "SHELL_RUNNING";
1157
-
1158
- const transcriptEventCount = staticEvents.length + activeEvents.length;
1159
-
1160
- const effectiveSnapshot = useMemo(() => {
1161
- // If we have events but the live snapshot is empty, fallback to the last
1162
- // known good snapshot to avoid blanking the screen during transitions.
1163
- if (liveSnapshot.totalRows === 0 && transcriptEventCount > 0 && lastNonEmptySnapshotRef.current.totalRows > 0) {
1164
- renderDebug.traceFlickerEvent("snapshotFallback", {
1165
- reason: "empty-while-busy-with-events",
1166
- uiStateKind: uiState.kind,
1167
- previousTotalRows: lastNonEmptySnapshotRef.current.totalRows,
1168
- transcriptEventCount,
1169
- });
1170
- return lastNonEmptySnapshotRef.current;
1171
- }
1172
- return liveSnapshot;
1173
- }, [liveSnapshot, transcriptEventCount, uiState.kind]);
1174
- const snapshotForViewport = effectiveSnapshot;
1175
-
1176
- const [viewport, setViewport] = useState<TimelineViewportState>(() => createFollowTailViewport(snapshotForViewport.totalRows));
1177
- const liveSnapshotRef = useRef(snapshotForViewport);
1178
- // Tracks the previous snapshotWidth so we can detect width changes inside
1179
- // the liveSnapshot effect and dispatch reflowTimelineViewport instead of
1180
- // syncTimelineViewport when the terminal has been resized.
1181
- const snapshotWidthRef = useRef(snapshotWidth);
1182
- // Tracks previous totalRows so we can skip setViewport when no new rows
1183
- // arrived avoiding a second React render / Ink stdout write per streaming
1184
- // flush when the viewport already reflects the correct state.
1185
- const prevTotalRowsRef = useRef(effectiveSnapshot.totalRows);
1186
- // Stable ref so the raw stdin wheel listener always reads the latest
1187
- // viewportRows without needing to re-register the listener on resize.
1188
- const viewportRowsRef = useRef(viewportRows);
1189
-
1190
- const { stdin } = useStdin();
1191
-
1192
- useEffect(() => {
1193
- liveSnapshotRef.current = snapshotForViewport;
1194
- }, [snapshotForViewport]);
1195
-
1196
- useEffect(() => {
1197
- viewportRowsRef.current = viewportRows;
1198
- }, [viewportRows]);
1199
-
1200
- // Raw stdin listener for SGR mouse wheel events. Ink's readline layer can
1201
- // fragment escape sequences before they reach useInput, so we parse the raw
1202
- // bytes directly — the same approach BottomComposer uses to detect mouse
1203
- // events. Only wheel button codes (64/65) are acted upon; clicks are ignored.
1204
- useEffect(() => {
1205
- if (!mouseCapture || !stdin) return;
1206
-
1207
- function handleRawWheel(chunk: Buffer | string) {
1208
- const raw = typeof chunk === "string" ? chunk : chunk.toString("utf8");
1209
- const directions = parseWheelScrollDirections(raw);
1210
- if (directions.length === 0) return;
1211
- const delta = directions.reduce(
1212
- (acc, dir) => acc + (dir === "up" ? -WHEEL_SCROLL_STEP : WHEEL_SCROLL_STEP),
1213
- 0,
1214
- );
1215
- if (delta === 0) return;
1216
- setViewport((current) =>
1217
- scrollTimelineViewport(current, liveSnapshotRef.current, viewportRowsRef.current, delta),
1218
- );
1219
- onMouseActivity?.();
1220
- }
1221
-
1222
- stdin.on("data", handleRawWheel);
1223
- return () => { stdin.off("data", handleRawWheel); };
1224
- }, [mouseCapture, stdin]);
1225
-
1226
- useEffect(() => {
1227
- const widthChanged = snapshotWidthRef.current !== snapshotWidth;
1228
- snapshotWidthRef.current = snapshotWidth;
1229
-
1230
- const totalRows = snapshotForViewport.totalRows;
1231
- const previousTotalRows = prevTotalRowsRef.current;
1232
- const rowGrowth = totalRows - previousTotalRows;
1233
- const totalRowsGrew = rowGrowth > 0;
1234
- prevTotalRowsRef.current = totalRows;
1235
-
1236
- setViewport((current) => {
1237
- // Width change: always reflow or sync to wrap text at the new width.
1238
- if (widthChanged) {
1239
- const next = !current.followTail && current.frozenSnapshot !== null
1240
- ? reflowTimelineViewport(current, snapshotForViewport)
1241
- : syncTimelineViewport(current, snapshotForViewport);
1242
- renderDebug.traceFlickerEvent("viewportSync", {
1243
- reason: "width-change",
1244
- result: next === current ? "skipped" : "updated",
1245
- previousTotalRows,
1246
- totalRows,
1247
- rowGrowth,
1248
- previousAnchorRow: current.anchorRow,
1249
- anchorRow: next.anchorRow,
1250
- followTail: next.followTail,
1251
- viewportRows,
1252
- });
1253
- return next;
1254
- }
1255
-
1256
- // Frozen (user scrolled up): keep the frozen snapshot fresh when rows
1257
- // grow or the stream finalizes so the unseen-item counters and
1258
- // jump-to-bottom affordance stay accurate.
1259
- if (!current.followTail) {
1260
- const next = totalRowsGrew || finalizeTransition
1261
- ? syncTimelineViewport(current, snapshotForViewport)
1262
- : current;
1263
- renderDebug.traceFlickerEvent("viewportSync", {
1264
- reason: finalizeTransition
1265
- ? (totalRowsGrew ? "detached-finalize-growth" : "detached-finalize")
1266
- : (totalRowsGrew ? "detached-growth" : "detached-no-growth"),
1267
- result: next === current ? "skipped" : "updated",
1268
- previousTotalRows,
1269
- totalRows,
1270
- rowGrowth,
1271
- previousAnchorRow: current.anchorRow,
1272
- anchorRow: next.anchorRow,
1273
- followTail: next.followTail,
1274
- viewportRows,
1275
- });
1276
- return next;
1277
- }
1278
-
1279
- // Follow-tail: only advance anchorRow when content actually grew.
1280
- // Streaming is append-only so shrinking rows is rare; if it happens the
1281
- // anchorRow stays at the old position until the next growth tick.
1282
- if (!totalRowsGrew) {
1283
- renderDebug.traceFlickerEvent("viewportSync", {
1284
- reason: "follow-tail-no-growth",
1285
- result: "skipped",
1286
- previousTotalRows,
1287
- totalRows,
1288
- rowGrowth,
1289
- previousAnchorRow: current.anchorRow,
1290
- anchorRow: current.anchorRow,
1291
- followTail: current.followTail,
1292
- viewportRows,
1293
- });
1294
- return current;
1295
- }
1296
-
1297
- const useFinalizeContinuity = finalizeTransition
1298
- && !finalizedPlanAtTail
1299
- && rowGrowth > Math.max(1, Math.floor(viewportRows / 2));
1300
- const next = syncTimelineViewport(current, snapshotForViewport, {
1301
- finalizeContinuity: useFinalizeContinuity
1302
- ? { previousTotalRows, viewportRows }
1303
- : undefined,
1304
- });
1305
- renderDebug.traceFlickerEvent("viewportSync", {
1306
- reason: useFinalizeContinuity ? "finalize-continuity" : "follow-tail-growth",
1307
- result: next === current ? "skipped" : "updated",
1308
- previousTotalRows,
1309
- totalRows,
1310
- rowGrowth,
1311
- previousAnchorRow: current.anchorRow,
1312
- anchorRow: next.anchorRow,
1313
- followTail: next.followTail,
1314
- viewportRows,
1315
- });
1316
- return next;
1317
- });
1318
- }, [finalizeTransition, finalizedPlanAtTail, snapshotForViewport, snapshotWidth, viewportRows]);
1319
-
1320
- useEffect(() => {
1321
- finalizeTransitionRef.current = {
1322
- runningTurnIds,
1323
- finalizedTurnIds,
1324
- busy: isBusyUiState(uiState),
1325
- };
1326
- }, [finalizedTurnIds, runningTurnIds, uiState]);
1327
-
1328
- useInput((input, key) => {
1329
- const currentSnapshot = liveSnapshotRef.current;
1330
- if (currentSnapshot.totalRows === 0) return;
1331
-
1332
- const rawInput = input.includes("\u001b") ? input : `\u001b${input}`;
1333
- const wheelDelta = parseTimelineNavigationInput(rawInput).reduce((deltaRows, action) => {
1334
- if (action === "wheelUp") return deltaRows - WHEEL_SCROLL_STEP;
1335
- if (action === "wheelDown") return deltaRows + WHEEL_SCROLL_STEP;
1336
- return deltaRows;
1337
- }, 0);
1338
-
1339
- if (wheelDelta !== 0) {
1340
- setViewport((current) => scrollTimelineViewport(current, currentSnapshot, viewportRows, wheelDelta));
1341
- return;
1342
- }
1343
-
1344
- if (key.pageUp) {
1345
- setViewport((current) => pageUpTimelineViewport(current, currentSnapshot, viewportRows));
1346
- return;
1347
- }
1348
-
1349
- if (key.pageDown) {
1350
- setViewport((current) => pageDownTimelineViewport(current, currentSnapshot, viewportRows));
1351
- return;
1352
- }
1353
-
1354
- if (key.home || isHomeInput(input)) {
1355
- setViewport((current) => homeTimelineViewport(current, currentSnapshot, viewportRows));
1356
- return;
1357
- }
1358
-
1359
- if (key.end || isEndInput(input)) {
1360
- setViewport(endTimelineViewport(currentSnapshot.totalRows));
1361
- }
1362
- });
1363
-
1364
- const { visibleRows } = useMemo(() => {
1365
- const selection = selectTimelineRows(snapshotForViewport, viewport, viewportRows);
1366
- renderDebug.traceEvent("viewport", "slice", {
1367
- providerState: uiState.kind,
1368
- visibleRows: selection.visibleRows.length,
1369
- visibleStart: selection.window.startRow,
1370
- visibleEnd: selection.window.endRow,
1371
- anchorRow: selection.window.anchorRow,
1372
- maxScrollOffset: Math.max(0, selection.sourceSnapshot.totalRows - viewportRows),
1373
- followTail: viewport.followTail,
1374
- totalItems: selection.sourceSnapshot.itemCount,
1375
- totalRows: selection.sourceSnapshot.totalRows,
1376
- availableTimelineRows: viewportRows,
1377
- sliceEmpty: selection.visibleRows.length === 0 && selection.sourceSnapshot.totalRows > 0,
1378
- fallbackSnapshot: snapshotForViewport !== liveSnapshot,
1379
- });
1380
- renderDebug.traceFlickerEvent("viewportSlice", {
1381
- visibleRows: selection.visibleRows.length,
1382
- startRow: selection.window.startRow,
1383
- endRow: selection.window.endRow,
1384
- anchorRow: selection.window.anchorRow,
1385
- followTail: viewport.followTail,
1386
- totalRows: selection.sourceSnapshot.totalRows,
1387
- fallbackSnapshot: snapshotForViewport !== liveSnapshot,
1388
- });
1389
- return selection;
1390
- }, [snapshotForViewport, viewport, viewportRows]);
1391
- const lastNonEmptyVisibleRowsRef = useRef<TimelineRow[]>([]);
1392
- if (visibleRows.length > 0) {
1393
- lastNonEmptyVisibleRowsRef.current = visibleRows;
1394
- }
1395
- const usingLastGoodFrameFallback = visibleRows.length === 0 && transcriptEventCount > 0;
1396
- if (usingLastGoodFrameFallback) {
1397
- renderDebug.traceEvent("viewport", "lastGoodFrameFallback", {
1398
- providerState: uiState.kind,
1399
- totalRows: liveSnapshot.totalRows,
1400
- availableTimelineRows: viewportRows,
1401
- preservedRows: lastNonEmptyVisibleRowsRef.current.length,
1402
- });
1403
- }
1404
- const preservedVisibleRows = usingLastGoodFrameFallback
1405
- ? lastNonEmptyVisibleRowsRef.current
1406
- : visibleRows;
1407
-
1408
- const showJumpToBottom = !viewport.followTail;
1409
- const rowsForDisplay = showJumpToBottom
1410
- ? preservedVisibleRows.slice(0, Math.max(0, viewportRows - 1))
1411
- : preservedVisibleRows;
1412
-
1413
- if (visibleRows.length === 0) {
1414
- renderDebug.traceBlankFrame("Timeline", {
1415
- reason: transcriptEventCount > 0 ? "visible-rows-zero-with-events" : "visible-rows-zero-no-events",
1416
- staticEventsLength: staticEvents.length,
1417
- activeEventsLength: activeEvents.length,
1418
- transcriptEventCount,
1419
- totalRows: liveSnapshot.totalRows,
1420
- viewportRows,
1421
- preservedRows: preservedVisibleRows.length,
1422
- uiStateKind: uiState.kind,
1423
- });
1424
- }
1425
-
1426
- if (preservedVisibleRows.length === 0) {
1427
- renderDebug.traceBlankFrame("Timeline", {
1428
- reason: "empty-root-layout-return",
1429
- staticEventsLength: staticEvents.length,
1430
- activeEventsLength: activeEvents.length,
1431
- transcriptEventCount,
1432
- totalRows: liveSnapshot.totalRows,
1433
- effectiveTotalRows: snapshotForViewport.totalRows,
1434
- viewportRows,
1435
- uiStateKind: uiState.kind,
1436
- });
1437
- return <Box flexDirection="column" width="100%" height={contentSized ? undefined : Math.max(1, viewportRows)} />;
1438
- }
1439
-
1440
- return (
1441
- <Box
1442
- flexDirection="column"
1443
- width="100%"
1444
- height={contentSized ? undefined : Math.max(1, viewportRows)}
1445
- overflow="hidden"
1446
- >
1447
- <TimelineRowsView rows={rowsForDisplay} />
1448
- {showJumpToBottom && (
1449
- <JumpToBottomBar unseenItems={viewport.unseenItems} mouseCapture={mouseCapture} />
1450
- )}
1451
- </Box>
1452
- );
1453
- }, (prev, next) => {
1454
- return (
1455
- prev.staticEvents === next.staticEvents &&
1456
- prev.activeEvents === next.activeEvents &&
1457
- prev.layout.cols === next.layout.cols &&
1458
- prev.layout.rows === next.layout.rows &&
1459
- prev.layout.mode === next.layout.mode &&
1460
- prev.uiState === next.uiState &&
1461
- prev.viewportRows === next.viewportRows &&
1462
- prev.verboseMode === next.verboseMode &&
1463
- prev.authState === next.authState &&
1464
- prev.workspaceLabel === next.workspaceLabel &&
1465
- prev.workspaceRoot === next.workspaceRoot &&
1466
- prev.mouseCapture === next.mouseCapture &&
1467
- prev.onMouseActivity === next.onMouseActivity &&
1468
- prev.contentSized === next.contentSized
1469
- );
1470
- });
1471
-
1472
- export { Timeline as ActiveTimeline };
1
+ import React, { memo, useEffect, useMemo, useRef, useState } from "react";
2
+ import { Box, Text, useInput, useStdin } from "ink";
3
+ import {
4
+ getRunPlanText,
5
+ type AssistantEvent,
6
+ type ErrorEvent,
7
+ type RunEvent,
8
+ type ShellEvent,
9
+ type SystemEvent,
10
+ type TimelineEvent,
11
+ type UIState,
12
+ type UserPromptEvent,
13
+ } from "../session/types.js";
14
+ import { APP_VERSION } from "../config/settings.js";
15
+ import { formatCodexaVersionLabel } from "../core/version/channel.js";
16
+ import type { CodexAuthState } from "../core/auth/codexAuth.js";
17
+ import { getAuthStateLabel } from "../core/auth/codexAuth.js";
18
+ import * as renderDebug from "../core/perf/renderDebug.js";
19
+ import { getShellWidth, type Layout, type StartupHeaderMode } from "./layout.js";
20
+ import type { TimelineRow, TimelineSnapshot, TimelineTone } from "./timelineMeasure.js";
21
+ import { buildStableTimelineSnapshot, buildTimelineSnapshot } from "./timelineMeasure.js";
22
+ import { resolveTurnRunPhase, type TurnOpacity, type TurnRunPhase } from "./TurnGroup.js";
23
+ import { useTheme } from "./theme.js";
24
+
25
+ // ─── Types & constants ────────────────────────────────────────────────────────
26
+
27
+ interface TimelineProps {
28
+ staticEvents: TimelineEvent[];
29
+ activeEvents: TimelineEvent[];
30
+ layout: Layout;
31
+ uiState: UIState;
32
+ viewportRows: number;
33
+ verboseMode?: boolean;
34
+ authState?: CodexAuthState;
35
+ workspaceLabel?: string;
36
+ workspaceRoot?: string | null;
37
+ mouseCapture?: boolean;
38
+ onMouseActivity?: () => void;
39
+ contentSized?: boolean;
40
+ }
41
+
42
+ type StandaloneTimelineEvent = SystemEvent | ErrorEvent | ShellEvent;
43
+
44
+ interface TurnTimelineItem {
45
+ type: "turn";
46
+ turnId: number;
47
+ turnIndex: number;
48
+ user: UserPromptEvent | null;
49
+ run: RunEvent | null;
50
+ assistant: AssistantEvent | null;
51
+ }
52
+
53
+ interface EventTimelineItem {
54
+ type: "event";
55
+ event: StandaloneTimelineEvent;
56
+ }
57
+
58
+ export type TimelineItem = TurnTimelineItem | EventTimelineItem;
59
+
60
+ export interface TurnRenderState {
61
+ opacity: TurnOpacity;
62
+ question: string | null;
63
+ runPhase: TurnRunPhase;
64
+ }
65
+
66
+ interface TurnRenderTimelineItem {
67
+ key: string;
68
+ type: "turn";
69
+ padded: boolean;
70
+ item: TurnTimelineItem;
71
+ renderState: TurnRenderState;
72
+ }
73
+
74
+ interface EventRenderTimelineItem {
75
+ key: string;
76
+ type: "event";
77
+ padded: boolean;
78
+ event: StandaloneTimelineEvent;
79
+ }
80
+
81
+ export interface IntroRenderTimelineItem {
82
+ key: string;
83
+ type: "intro";
84
+ padded: boolean;
85
+ intro: {
86
+ version: string;
87
+ layoutMode: Layout["mode"];
88
+ startupHeaderMode?: StartupHeaderMode;
89
+ authLabel: string;
90
+ workspaceLabel: string;
91
+ providerLabel?: string | null;
92
+ };
93
+ }
94
+
95
+ export type RenderTimelineItem = IntroRenderTimelineItem | TurnRenderTimelineItem | EventRenderTimelineItem;
96
+
97
+ const WHEEL_SCROLL_STEP = 3;
98
+ // Re-enter follow-tail mode when the user scrolls within this many rows of the
99
+ // tail, preventing a "stuck just above bottom" state after a near-end wheel scroll.
100
+ const NEAR_BOTTOM_THRESHOLD = 3;
101
+ const PAGE_UP_KEY_INPUTS = new Set(["\u001b[5~", "\u001b[[5~", "\u001b[5;2~", "\u001b[5;5~"]);
102
+ const PAGE_DOWN_KEY_INPUTS = new Set(["\u001b[6~", "\u001b[[6~", "\u001b[6;2~", "\u001b[6;5~"]);
103
+ const HOME_KEY_INPUTS = new Set(["\u001b[H", "\u001b[1~", "\u001bOH", "\u001b[1;5H", "\u001b[1;2H"]);
104
+ const END_KEY_INPUTS = new Set(["\u001b[F", "\u001b[4~", "\u001bOF", "\u001b[1;5F", "\u001b[1;2F"]);
105
+ const CTRL_HOME_KEY_INPUTS = new Set(["\u001b[1;5H", "\u001b[H"]);
106
+ const CTRL_END_KEY_INPUTS = new Set(["\u001b[1;5F", "\u001b[F"]);
107
+ const SGR_WHEEL_EVENT_PATTERN = /\u001b\[<(\d+);(\d+);(\d+)([Mm])/g;
108
+ const STABLE_RENDER_ENABLED = process.env.CODEXA_STABLE_RENDER !== "0";
109
+
110
+ type TimelineNavigationAction = "pageUp" | "pageDown" | "home" | "end" | "wheelUp" | "wheelDown";
111
+
112
+ export interface TimelineViewportState {
113
+ anchorRow: number;
114
+ followTail: boolean;
115
+ unseenItems: number;
116
+ unseenRows: number;
117
+ frozenSnapshot: TimelineSnapshot | null;
118
+ }
119
+
120
+ interface FinalizeContinuityOptions {
121
+ previousTotalRows: number;
122
+ viewportRows: number;
123
+ }
124
+
125
+ // ─── Viewport helpers ────────────────────────────────────────────────────────
126
+
127
+ function isStandaloneEvent(event: TimelineEvent): event is StandaloneTimelineEvent {
128
+ return event.type === "system" || event.type === "error" || event.type === "shell";
129
+ }
130
+
131
+ function getActiveTurnId(uiState: UIState): number | null {
132
+ return uiState.kind === "THINKING"
133
+ || uiState.kind === "RESPONDING"
134
+ || uiState.kind === "ANSWER_VISIBLE"
135
+ || uiState.kind === "AWAITING_USER_ACTION"
136
+ || uiState.kind === "ERROR"
137
+ ? uiState.turnId
138
+ : null;
139
+ }
140
+
141
+ function isBusyUiState(uiState: UIState): boolean {
142
+ return uiState.kind === "THINKING"
143
+ || uiState.kind === "RESPONDING"
144
+ || uiState.kind === "ANSWER_VISIBLE"
145
+ || uiState.kind === "SHELL_RUNNING";
146
+ }
147
+
148
+ function getRunningTurnIds(events: TimelineEvent[]): number[] {
149
+ return events
150
+ .filter((event): event is RunEvent => event.type === "run" && event.status === "running")
151
+ .map((event) => event.turnId);
152
+ }
153
+
154
+ function getFinalizedTurnIds(events: TimelineEvent[]): number[] {
155
+ return events
156
+ .filter((event): event is RunEvent => event.type === "run" && event.status !== "running")
157
+ .map((event) => event.turnId);
158
+ }
159
+
160
+ function latestFinalizedRunEndsWithPlan(events: TimelineEvent[]): boolean {
161
+ for (let index = events.length - 1; index >= 0; index -= 1) {
162
+ const event = events[index];
163
+ if (!event || event.type !== "run" || event.status === "running") {
164
+ continue;
165
+ }
166
+
167
+ const lastStreamItem = (event.streamItems ?? [])
168
+ .slice()
169
+ .sort((left, right) => left.streamSeq - right.streamSeq)
170
+ .at(-1);
171
+ return lastStreamItem?.kind === "plan"
172
+ && event.plan?.status === "completed"
173
+ && getRunPlanText(event.plan).trim().length > 0;
174
+ }
175
+
176
+ return false;
177
+ }
178
+
179
+ function hasFinalizeTransition(params: {
180
+ previousRunningTurnIds: number[];
181
+ nextRunningTurnIds: number[];
182
+ nextFinalizedTurnIds: number[];
183
+ previousBusy: boolean;
184
+ nextBusy: boolean;
185
+ }): boolean {
186
+ if (!params.previousBusy || params.nextBusy) return false;
187
+ return params.previousRunningTurnIds.some((turnId) =>
188
+ !params.nextRunningTurnIds.includes(turnId)
189
+ && params.nextFinalizedTurnIds.includes(turnId)
190
+ );
191
+ }
192
+
193
+ function isHomeInput(input: string): boolean {
194
+ return HOME_KEY_INPUTS.has(input);
195
+ }
196
+
197
+ function isEndInput(input: string): boolean {
198
+ return END_KEY_INPUTS.has(input);
199
+ }
200
+
201
+ function rawIncludesAny(raw: string, inputs: ReadonlySet<string>): boolean {
202
+ for (const input of inputs) {
203
+ if (raw.includes(input)) {
204
+ return true;
205
+ }
206
+ }
207
+ return false;
208
+ }
209
+
210
+ function clampAnchorRow(anchorRow: number, totalRows: number): number {
211
+ if (totalRows <= 0) {
212
+ return 0;
213
+ }
214
+
215
+ return Math.max(0, Math.min(anchorRow, totalRows - 1));
216
+ }
217
+
218
+ export function isNearBottom(anchorRow: number, totalRows: number): boolean {
219
+ return totalRows <= 0 || anchorRow >= totalRows - 1 - NEAR_BOTTOM_THRESHOLD;
220
+ }
221
+
222
+ function getFirstPageAnchor(totalRows: number, viewportRows: number): number {
223
+ if (totalRows <= 0) {
224
+ return 0;
225
+ }
226
+ return Math.min(totalRows - 1, Math.max(0, viewportRows - 1));
227
+ }
228
+
229
+ // ─── Timeline item builders ───────────────────────────────────────────────────
230
+
231
+ export function buildTimelineItems(events: TimelineEvent[]): TimelineItem[] {
232
+ const items: TimelineItem[] = [];
233
+ const turns = new Map<number, TurnTimelineItem>();
234
+ let nextTurnIndex = 1;
235
+
236
+ for (const event of events) {
237
+ if (isStandaloneEvent(event)) {
238
+ items.push({ type: "event", event });
239
+ continue;
240
+ }
241
+
242
+ const turnId = event.turnId;
243
+ let turn = turns.get(turnId);
244
+ if (!turn) {
245
+ turn = {
246
+ type: "turn",
247
+ turnId,
248
+ turnIndex: nextTurnIndex++,
249
+ user: null,
250
+ run: null,
251
+ assistant: null,
252
+ };
253
+ turns.set(turnId, turn);
254
+ items.push(turn);
255
+ }
256
+
257
+ if (event.type === "user") {
258
+ turn.user = event;
259
+ } else if (event.type === "run") {
260
+ turn.run = event;
261
+ } else if (event.type === "assistant") {
262
+ turn.assistant = event;
263
+ }
264
+ }
265
+
266
+ return items.filter((item) => item.type === "event" || item.user !== null);
267
+ }
268
+
269
+ export function resolveTurnOpacity(turnIds: number[], turnId: number, activeTurnId: number | null): TurnOpacity {
270
+ if (turnIds.length === 0) return "dim";
271
+
272
+ if (activeTurnId === null) {
273
+ return turnId === turnIds[turnIds.length - 1] ? "recent" : "dim";
274
+ }
275
+
276
+ const activeIndex = turnIds.indexOf(activeTurnId);
277
+ const currentIndex = turnIds.indexOf(turnId);
278
+ if (currentIndex === activeIndex) return "active";
279
+ if (currentIndex === activeIndex - 1) return "recent";
280
+ return "dim";
281
+ }
282
+
283
+ export function createFollowTailViewport(totalRows: number): TimelineViewportState {
284
+ return {
285
+ anchorRow: Math.max(0, totalRows - 1),
286
+ followTail: true,
287
+ unseenItems: 0,
288
+ unseenRows: 0,
289
+ frozenSnapshot: null,
290
+ };
291
+ }
292
+
293
+ function createAnchoredViewport(snapshot: TimelineSnapshot, anchorRow: number): TimelineViewportState {
294
+ return {
295
+ anchorRow: clampAnchorRow(anchorRow, snapshot.totalRows),
296
+ followTail: false,
297
+ unseenItems: 0,
298
+ unseenRows: 0,
299
+ frozenSnapshot: snapshot,
300
+ };
301
+ }
302
+
303
+ function findFinalResponseStartRow(snapshot: TimelineSnapshot, previousTotalRows: number, viewportRows: number): number | null {
304
+ const searchFloor = Math.max(0, previousTotalRows - Math.max(1, viewportRows));
305
+ const responseIndex = snapshot.rows.findIndex((row, index) =>
306
+ index >= searchFloor && row.key.includes("-codex-response-")
307
+ );
308
+ return responseIndex >= 0 ? responseIndex : null;
309
+ }
310
+
311
+ export function createFinalizeContinuityViewport(
312
+ snapshot: TimelineSnapshot,
313
+ options: FinalizeContinuityOptions,
314
+ ): TimelineViewportState {
315
+ if (snapshot.totalRows === 0) {
316
+ return createFollowTailViewport(0);
317
+ }
318
+
319
+ const responseStartRow = findFinalResponseStartRow(
320
+ snapshot,
321
+ options.previousTotalRows,
322
+ options.viewportRows,
323
+ );
324
+ const answerPreviewRows = Math.min(3, Math.max(1, Math.floor(options.viewportRows / 4)));
325
+ const fallbackAnchor = options.previousTotalRows + answerPreviewRows - 1;
326
+ const anchorRow = responseStartRow === null
327
+ ? fallbackAnchor
328
+ : responseStartRow + answerPreviewRows - 1;
329
+
330
+ return createAnchoredViewport(snapshot, Math.min(snapshot.totalRows - 1, Math.max(0, anchorRow)));
331
+ }
332
+
333
+ // ─── Viewport operations ─────────────────────────────────────────────────────
334
+
335
+ function getFrozenSnapshot(
336
+ viewport: TimelineViewportState,
337
+ liveSnapshot: TimelineSnapshot,
338
+ ): TimelineSnapshot {
339
+ return viewport.followTail || viewport.frozenSnapshot === null
340
+ ? liveSnapshot
341
+ : viewport.frozenSnapshot;
342
+ }
343
+
344
+ export function syncTimelineViewport(
345
+ viewport: TimelineViewportState,
346
+ liveSnapshot: TimelineSnapshot,
347
+ options: { finalizeContinuity?: FinalizeContinuityOptions } = {},
348
+ ): TimelineViewportState {
349
+ if (liveSnapshot.totalRows === 0) {
350
+ // Preserve a frozen scroll offset through a transient empty-snapshot moment so
351
+ // the user's reading position is not reset to row 0. Only reset when the
352
+ // viewport is already in follow-tail mode (there is nothing meaningful to preserve).
353
+ return viewport.followTail ? createFollowTailViewport(0) : viewport;
354
+ }
355
+
356
+ if (viewport.followTail) {
357
+ if (options.finalizeContinuity) {
358
+ return createFinalizeContinuityViewport(liveSnapshot, options.finalizeContinuity);
359
+ }
360
+
361
+ const nextAnchor = liveSnapshot.totalRows - 1;
362
+ if (
363
+ viewport.anchorRow === nextAnchor
364
+ && viewport.unseenItems === 0
365
+ && viewport.unseenRows === 0
366
+ && viewport.frozenSnapshot === null
367
+ ) {
368
+ return viewport;
369
+ }
370
+ return createFollowTailViewport(liveSnapshot.totalRows);
371
+ }
372
+
373
+ const frozenSnapshot = viewport.frozenSnapshot ?? liveSnapshot;
374
+ const anchorRow = clampAnchorRow(viewport.anchorRow, frozenSnapshot.totalRows);
375
+ const unseenItems = Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount);
376
+ const unseenRows = Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows);
377
+
378
+ if (
379
+ viewport.anchorRow === anchorRow
380
+ && viewport.unseenItems === unseenItems
381
+ && viewport.unseenRows === unseenRows
382
+ && viewport.frozenSnapshot === frozenSnapshot
383
+ ) {
384
+ return viewport;
385
+ }
386
+
387
+ return {
388
+ anchorRow,
389
+ followTail: false,
390
+ unseenItems,
391
+ unseenRows,
392
+ frozenSnapshot,
393
+ };
394
+ }
395
+
396
+ /**
397
+ * Returns the item index and row-within-item for the given absolute anchorRow
398
+ * inside a snapshot. Used by reflowTimelineViewport to translate a row-based
399
+ * anchor into a layout-independent (item, offset) anchor.
400
+ */
401
+ export function findAnchorItem(
402
+ snapshot: TimelineSnapshot,
403
+ anchorRow: number,
404
+ ): { itemIndex: number; rowWithinItem: number } {
405
+ let rowOffset = 0;
406
+ for (let i = 0; i < snapshot.items.length; i++) {
407
+ const item = snapshot.items[i]!;
408
+ if (rowOffset + item.rowCount > anchorRow) {
409
+ return { itemIndex: i, rowWithinItem: anchorRow - rowOffset };
410
+ }
411
+ rowOffset += item.rowCount;
412
+ }
413
+ // anchorRow is at or beyond the last item – clamp to end
414
+ const lastIdx = Math.max(0, snapshot.items.length - 1);
415
+ const lastItem = snapshot.items[lastIdx];
416
+ return {
417
+ itemIndex: lastIdx,
418
+ rowWithinItem: lastItem ? Math.max(0, lastItem.rowCount - 1) : 0,
419
+ };
420
+ }
421
+
422
+ /**
423
+ * Rebuilds the frozen viewport snapshot after a terminal width change.
424
+ *
425
+ * When the terminal is resized (width changes), all snapshot memos are
426
+ * rebuilt at the new snapshotWidth, so liveSnapshot already contains
427
+ * correctly reflowed rows. However syncTimelineViewport preserves the
428
+ * old frozenSnapshot (with old-width rows), causing visual corruption.
429
+ *
430
+ * This function replaces the stale frozen snapshot with a new one built
431
+ * from the same items as before (liveSnapshot.items[0..frozenItemCount]),
432
+ * now correctly wrapped at the new width. The anchorRow is translated
433
+ * from the old layout via an item-level anchor so the user's reading
434
+ * position is preserved across reflow.
435
+ */
436
+ export function reflowTimelineViewport(
437
+ viewport: TimelineViewportState,
438
+ liveSnapshot: TimelineSnapshot,
439
+ ): TimelineViewportState {
440
+ if (liveSnapshot.totalRows === 0) {
441
+ return createFollowTailViewport(0);
442
+ }
443
+
444
+ if (viewport.followTail) {
445
+ return createFollowTailViewport(liveSnapshot.totalRows);
446
+ }
447
+
448
+ const oldFrozen = viewport.frozenSnapshot ?? liveSnapshot;
449
+
450
+ // Translate anchorRow → stable (item, rowWithinItem) anchor
451
+ const clampedAnchor = clampAnchorRow(viewport.anchorRow, oldFrozen.totalRows);
452
+ const { itemIndex: anchorItemIdx, rowWithinItem: anchorRowWithinItem } =
453
+ findAnchorItem(oldFrozen, clampedAnchor);
454
+
455
+ // Grab the same items from liveSnapshot (already reflowed at new width)
456
+ const frozenItemCount = oldFrozen.itemCount;
457
+ const newFrozenItems = liveSnapshot.items.slice(0, frozenItemCount);
458
+
459
+ if (newFrozenItems.length === 0) {
460
+ return createFollowTailViewport(liveSnapshot.totalRows);
461
+ }
462
+
463
+ // Assemble a new frozen snapshot from the reflowed items
464
+ const newFrozenRows = newFrozenItems.flatMap((item) => item.rows);
465
+ const newFrozenSnapshot: TimelineSnapshot = {
466
+ items: newFrozenItems,
467
+ rows: newFrozenRows,
468
+ totalRows: newFrozenRows.length,
469
+ itemCount: frozenItemCount,
470
+ };
471
+
472
+ // Reconstruct anchorRow in the new layout
473
+ let newAnchorRow = 0;
474
+ for (let i = 0; i < anchorItemIdx; i++) {
475
+ newAnchorRow += newFrozenItems[i]!.rowCount;
476
+ }
477
+ const targetItem = newFrozenItems[anchorItemIdx];
478
+ if (targetItem) {
479
+ newAnchorRow += Math.min(anchorRowWithinItem, targetItem.rowCount - 1);
480
+ } else {
481
+ // Anchor item is beyond the new frozen range (unseen) – clamp to end
482
+ newAnchorRow = Math.max(0, newFrozenSnapshot.totalRows - 1);
483
+ }
484
+ newAnchorRow = clampAnchorRow(newAnchorRow, newFrozenSnapshot.totalRows);
485
+
486
+ const unseenItems = Math.max(0, liveSnapshot.itemCount - frozenItemCount);
487
+ const unseenRows = Math.max(0, liveSnapshot.totalRows - newFrozenSnapshot.totalRows);
488
+
489
+ return {
490
+ anchorRow: newAnchorRow,
491
+ followTail: false,
492
+ unseenItems,
493
+ unseenRows,
494
+ frozenSnapshot: newFrozenSnapshot,
495
+ };
496
+ }
497
+
498
+ export function pageUpTimelineViewport(
499
+ viewport: TimelineViewportState,
500
+ liveSnapshot: TimelineSnapshot,
501
+ viewportRows: number,
502
+ ): TimelineViewportState {
503
+ if (liveSnapshot.totalRows === 0) {
504
+ return createFollowTailViewport(0);
505
+ }
506
+
507
+ const frozenSnapshot = getFrozenSnapshot(viewport, liveSnapshot);
508
+ const tailRow = Math.max(0, frozenSnapshot.totalRows - 1);
509
+ const currentAnchor = viewport.followTail
510
+ ? tailRow
511
+ : clampAnchorRow(viewport.anchorRow, frozenSnapshot.totalRows);
512
+ const nextAnchor = Math.max(getFirstPageAnchor(frozenSnapshot.totalRows, viewportRows), currentAnchor - Math.max(1, viewportRows));
513
+
514
+ return {
515
+ anchorRow: nextAnchor,
516
+ followTail: false,
517
+ unseenItems: Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount),
518
+ unseenRows: Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows),
519
+ frozenSnapshot,
520
+ };
521
+ }
522
+
523
+ export function pageDownTimelineViewport(
524
+ viewport: TimelineViewportState,
525
+ liveSnapshot: TimelineSnapshot,
526
+ viewportRows: number,
527
+ ): TimelineViewportState {
528
+ if (liveSnapshot.totalRows === 0 || viewport.followTail) {
529
+ return viewport;
530
+ }
531
+
532
+ const frozenSnapshot = viewport.frozenSnapshot ?? liveSnapshot;
533
+ const tailRow = Math.max(0, frozenSnapshot.totalRows - 1);
534
+ const currentAnchor = clampAnchorRow(viewport.anchorRow, frozenSnapshot.totalRows);
535
+ if (currentAnchor >= tailRow) {
536
+ return createFollowTailViewport(liveSnapshot.totalRows);
537
+ }
538
+
539
+ const nextAnchor = Math.min(tailRow, currentAnchor + Math.max(1, viewportRows));
540
+ if (nextAnchor >= tailRow) {
541
+ return createFollowTailViewport(liveSnapshot.totalRows);
542
+ }
543
+
544
+ return {
545
+ anchorRow: nextAnchor,
546
+ followTail: false,
547
+ unseenItems: Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount),
548
+ unseenRows: Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows),
549
+ frozenSnapshot,
550
+ };
551
+ }
552
+
553
+ export function halfPageUpTimelineViewport(
554
+ viewport: TimelineViewportState,
555
+ liveSnapshot: TimelineSnapshot,
556
+ viewportRows: number,
557
+ ): TimelineViewportState {
558
+ if (liveSnapshot.totalRows === 0) {
559
+ return createFollowTailViewport(0);
560
+ }
561
+
562
+ const frozenSnapshot = getFrozenSnapshot(viewport, liveSnapshot);
563
+ const tailRow = Math.max(0, frozenSnapshot.totalRows - 1);
564
+ const currentAnchor = viewport.followTail
565
+ ? tailRow
566
+ : clampAnchorRow(viewport.anchorRow, frozenSnapshot.totalRows);
567
+ const halfPage = Math.max(1, Math.floor(viewportRows / 2));
568
+ const nextAnchor = Math.max(getFirstPageAnchor(frozenSnapshot.totalRows, viewportRows), currentAnchor - halfPage);
569
+
570
+ return {
571
+ anchorRow: nextAnchor,
572
+ followTail: false,
573
+ unseenItems: Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount),
574
+ unseenRows: Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows),
575
+ frozenSnapshot,
576
+ };
577
+ }
578
+
579
+ export function halfPageDownTimelineViewport(
580
+ viewport: TimelineViewportState,
581
+ liveSnapshot: TimelineSnapshot,
582
+ viewportRows: number,
583
+ ): TimelineViewportState {
584
+ if (liveSnapshot.totalRows === 0 || viewport.followTail) {
585
+ return viewport;
586
+ }
587
+
588
+ const frozenSnapshot = viewport.frozenSnapshot ?? liveSnapshot;
589
+ const tailRow = Math.max(0, frozenSnapshot.totalRows - 1);
590
+ const currentAnchor = clampAnchorRow(viewport.anchorRow, frozenSnapshot.totalRows);
591
+ if (currentAnchor >= tailRow) {
592
+ return createFollowTailViewport(liveSnapshot.totalRows);
593
+ }
594
+
595
+ const halfPage = Math.max(1, Math.floor(viewportRows / 2));
596
+ const nextAnchor = Math.min(tailRow, currentAnchor + halfPage);
597
+ if (nextAnchor >= tailRow) {
598
+ return createFollowTailViewport(liveSnapshot.totalRows);
599
+ }
600
+
601
+ return {
602
+ anchorRow: nextAnchor,
603
+ followTail: false,
604
+ unseenItems: Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount),
605
+ unseenRows: Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows),
606
+ frozenSnapshot,
607
+ };
608
+ }
609
+
610
+ export function stepUpTimelineViewport(
611
+ viewport: TimelineViewportState,
612
+ liveSnapshot: TimelineSnapshot,
613
+ viewportRows: number,
614
+ ): TimelineViewportState {
615
+ if (liveSnapshot.totalRows === 0) {
616
+ return createFollowTailViewport(0);
617
+ }
618
+
619
+ const frozenSnapshot = getFrozenSnapshot(viewport, liveSnapshot);
620
+ const tailRow = Math.max(0, frozenSnapshot.totalRows - 1);
621
+ const currentAnchor = viewport.followTail
622
+ ? tailRow
623
+ : clampAnchorRow(viewport.anchorRow, frozenSnapshot.totalRows);
624
+ const floor = getFirstPageAnchor(frozenSnapshot.totalRows, viewportRows);
625
+
626
+ return {
627
+ anchorRow: Math.max(floor, currentAnchor - 1),
628
+ followTail: false,
629
+ unseenItems: Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount),
630
+ unseenRows: Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows),
631
+ frozenSnapshot,
632
+ };
633
+ }
634
+
635
+ export function stepDownTimelineViewport(
636
+ viewport: TimelineViewportState,
637
+ liveSnapshot: TimelineSnapshot,
638
+ _viewportRows?: number,
639
+ ): TimelineViewportState {
640
+ if (liveSnapshot.totalRows === 0 || viewport.followTail) {
641
+ return viewport;
642
+ }
643
+
644
+ const frozenSnapshot = viewport.frozenSnapshot ?? liveSnapshot;
645
+ const tailRow = Math.max(0, frozenSnapshot.totalRows - 1);
646
+ const currentAnchor = clampAnchorRow(viewport.anchorRow, frozenSnapshot.totalRows);
647
+ if (currentAnchor >= tailRow) {
648
+ return createFollowTailViewport(liveSnapshot.totalRows);
649
+ }
650
+
651
+ const nextAnchor = Math.min(tailRow, currentAnchor + 1);
652
+ if (nextAnchor >= tailRow) {
653
+ return createFollowTailViewport(liveSnapshot.totalRows);
654
+ }
655
+
656
+ return {
657
+ anchorRow: nextAnchor,
658
+ followTail: false,
659
+ unseenItems: Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount),
660
+ unseenRows: Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows),
661
+ frozenSnapshot,
662
+ };
663
+ }
664
+
665
+ export function scrollTimelineViewport(
666
+ viewport: TimelineViewportState,
667
+ liveSnapshot: TimelineSnapshot,
668
+ viewportRows: number,
669
+ deltaRows: number,
670
+ ): TimelineViewportState {
671
+ if (liveSnapshot.totalRows === 0) {
672
+ return createFollowTailViewport(0);
673
+ }
674
+ if (deltaRows === 0) {
675
+ return viewport;
676
+ }
677
+
678
+ const frozenSnapshot = getFrozenSnapshot(viewport, liveSnapshot);
679
+ const tailRow = Math.max(0, frozenSnapshot.totalRows - 1);
680
+
681
+ if (deltaRows > 0 && viewport.followTail) {
682
+ return viewport;
683
+ }
684
+
685
+ const currentAnchor = viewport.followTail
686
+ ? tailRow
687
+ : clampAnchorRow(viewport.anchorRow, frozenSnapshot.totalRows);
688
+
689
+ const floor = getFirstPageAnchor(frozenSnapshot.totalRows, viewportRows);
690
+
691
+ let nextAnchor = currentAnchor + deltaRows;
692
+
693
+ if (nextAnchor >= tailRow) {
694
+ return createFollowTailViewport(liveSnapshot.totalRows);
695
+ }
696
+
697
+ if (nextAnchor < floor) {
698
+ nextAnchor = floor;
699
+ }
700
+
701
+ return {
702
+ anchorRow: nextAnchor,
703
+ followTail: false,
704
+ unseenItems: Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount),
705
+ unseenRows: Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows),
706
+ frozenSnapshot,
707
+ };
708
+ }
709
+
710
+ export function homeTimelineViewport(
711
+ viewport: TimelineViewportState,
712
+ liveSnapshot: TimelineSnapshot,
713
+ viewportRows: number,
714
+ ): TimelineViewportState {
715
+ if (liveSnapshot.totalRows === 0) {
716
+ return createFollowTailViewport(0);
717
+ }
718
+
719
+ const frozenSnapshot = getFrozenSnapshot(viewport, liveSnapshot);
720
+ return {
721
+ anchorRow: getFirstPageAnchor(frozenSnapshot.totalRows, viewportRows),
722
+ followTail: false,
723
+ unseenItems: Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount),
724
+ unseenRows: Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows),
725
+ frozenSnapshot,
726
+ };
727
+ }
728
+
729
+ export function endTimelineViewport(totalRows: number): TimelineViewportState {
730
+ return createFollowTailViewport(totalRows);
731
+ }
732
+
733
+ export function parseWheelScrollDirections(raw: string): Array<"up" | "down"> {
734
+ const directions: Array<"up" | "down"> = [];
735
+
736
+ for (const match of raw.matchAll(SGR_WHEEL_EVENT_PATTERN)) {
737
+ const code = Number.parseInt(match[1] ?? "", 10);
738
+ const terminator = match[4];
739
+ if (terminator !== "M" || Number.isNaN(code) || (code & 64) !== 64) {
740
+ continue;
741
+ }
742
+
743
+ directions.push((code & 1) === 0 ? "up" : "down");
744
+ }
745
+
746
+ return directions;
747
+ }
748
+
749
+ export function parseTimelineNavigationInput(raw: string): TimelineNavigationAction[] {
750
+ const actions: TimelineNavigationAction[] = [];
751
+
752
+ if (rawIncludesAny(raw, PAGE_UP_KEY_INPUTS)) {
753
+ actions.push("pageUp");
754
+ }
755
+
756
+ if (rawIncludesAny(raw, PAGE_DOWN_KEY_INPUTS)) {
757
+ actions.push("pageDown");
758
+ }
759
+
760
+ if (isHomeInput(raw) || rawIncludesAny(raw, CTRL_HOME_KEY_INPUTS)) {
761
+ actions.push("home");
762
+ }
763
+
764
+ if (isEndInput(raw) || rawIncludesAny(raw, CTRL_END_KEY_INPUTS)) {
765
+ actions.push("end");
766
+ }
767
+
768
+ for (const direction of parseWheelScrollDirections(raw)) {
769
+ actions.push(direction === "up" ? "wheelUp" : "wheelDown");
770
+ }
771
+
772
+ return actions;
773
+ }
774
+
775
+ // ─── Row selection & render items ────────────────────────────────────────────
776
+
777
+ export function selectTimelineRows(
778
+ liveSnapshot: TimelineSnapshot,
779
+ viewport: TimelineViewportState,
780
+ viewportRows: number,
781
+ ): {
782
+ sourceSnapshot: TimelineSnapshot;
783
+ visibleRows: TimelineRow[];
784
+ window: {
785
+ startRow: number;
786
+ endRow: number;
787
+ anchorRow: number;
788
+ };
789
+ } {
790
+ const sourceSnapshot = viewport.followTail || viewport.frozenSnapshot === null
791
+ ? liveSnapshot
792
+ : viewport.frozenSnapshot;
793
+ const safeViewportRows = Math.max(1, viewportRows);
794
+
795
+ if (sourceSnapshot.totalRows === 0) {
796
+ return {
797
+ sourceSnapshot,
798
+ visibleRows: [],
799
+ window: { startRow: 0, endRow: 0, anchorRow: 0 },
800
+ };
801
+ }
802
+
803
+ const anchorRow = viewport.followTail
804
+ ? sourceSnapshot.totalRows - 1
805
+ : clampAnchorRow(viewport.anchorRow, sourceSnapshot.totalRows);
806
+ const endRow = Math.max(1, anchorRow + 1);
807
+ const startRow = Math.max(0, endRow - safeViewportRows);
808
+
809
+ const visibleRows = sourceSnapshot.rows.slice(startRow, endRow);
810
+
811
+ // Aggressive fallback: if slicing returned nothing but we have rows,
812
+ // return at least the last row to avoid a blank screen.
813
+ if (visibleRows.length === 0 && sourceSnapshot.totalRows > 0) {
814
+ const fallbackRow = sourceSnapshot.rows[sourceSnapshot.totalRows - 1]!;
815
+ return {
816
+ sourceSnapshot,
817
+ visibleRows: [fallbackRow],
818
+ window: {
819
+ startRow: sourceSnapshot.totalRows - 1,
820
+ endRow: sourceSnapshot.totalRows,
821
+ anchorRow: sourceSnapshot.totalRows - 1,
822
+ },
823
+ };
824
+ }
825
+
826
+ return {
827
+ sourceSnapshot,
828
+ visibleRows,
829
+ window: {
830
+ startRow,
831
+ endRow,
832
+ anchorRow,
833
+ },
834
+ };
835
+ }
836
+
837
+ export function buildStaticRenderItems(
838
+ items: TimelineItem[],
839
+ turnIds: number[],
840
+ activeTurnId: number | null,
841
+ questionTurnId: number | null,
842
+ question: string | null,
843
+ ): RenderTimelineItem[] {
844
+ return items.map((item) => {
845
+ if (item.type === "event") {
846
+ return {
847
+ key: `event-${item.event.id}`,
848
+ type: "event",
849
+ padded: false,
850
+ event: item.event,
851
+ };
852
+ }
853
+
854
+ return {
855
+ key: `turn-${item.turnId}`,
856
+ type: "turn",
857
+ padded: false,
858
+ item,
859
+ renderState: {
860
+ opacity: resolveTurnOpacity(turnIds, item.turnId, activeTurnId),
861
+ question: questionTurnId === item.turnId ? question : null,
862
+ runPhase: resolveTurnRunPhase(item.run, item.assistant, { kind: "IDLE" }, item.turnId),
863
+ },
864
+ };
865
+ });
866
+ }
867
+
868
+ export function buildActiveRenderItems(
869
+ items: TimelineItem[],
870
+ turnIds: number[],
871
+ uiState: UIState,
872
+ ): RenderTimelineItem[] {
873
+ const activeTurnId = getActiveTurnId(uiState);
874
+ const questionTurnId = uiState.kind === "AWAITING_USER_ACTION" ? uiState.turnId : null;
875
+ const question = uiState.kind === "AWAITING_USER_ACTION" ? uiState.question : null;
876
+
877
+ return items.map((item) => {
878
+ if (item.type === "event") {
879
+ return {
880
+ key: `event-${item.event.id}`,
881
+ type: "event",
882
+ padded: true,
883
+ event: item.event,
884
+ };
885
+ }
886
+
887
+ return {
888
+ key: `turn-${item.turnId}`,
889
+ type: "turn",
890
+ padded: false,
891
+ item,
892
+ renderState: {
893
+ opacity: resolveTurnOpacity(turnIds, item.turnId, activeTurnId),
894
+ question: questionTurnId === item.turnId ? question : null,
895
+ runPhase: resolveTurnRunPhase(item.run, item.assistant, uiState, item.turnId),
896
+ },
897
+ };
898
+ });
899
+ }
900
+
901
+ function formatAuthLabel(authState: CodexAuthState): string {
902
+ const raw = getAuthStateLabel(authState);
903
+ return raw.length > 0 ? raw[0]!.toUpperCase() + raw.slice(1) : raw;
904
+ }
905
+
906
+ export function buildIntroRenderItem(params: {
907
+ authState: CodexAuthState;
908
+ workspaceLabel: string;
909
+ layout: Layout;
910
+ startupHeaderMode?: StartupHeaderMode;
911
+ providerLabel?: string | null;
912
+ }): IntroRenderTimelineItem {
913
+ return {
914
+ key: "codexa-intro",
915
+ type: "intro",
916
+ padded: true,
917
+ intro: {
918
+ version: formatCodexaVersionLabel(APP_VERSION),
919
+ layoutMode: params.layout.mode,
920
+ startupHeaderMode: params.startupHeaderMode,
921
+ authLabel: formatAuthLabel(params.authState),
922
+ workspaceLabel: params.workspaceLabel,
923
+ providerLabel: params.providerLabel ?? null,
924
+ },
925
+ };
926
+ }
927
+
928
+ // ─── Row rendering ────────────────────────────────────────────────────────────
929
+
930
+ function getToneColor(theme: ReturnType<typeof useTheme>, tone: TimelineTone | undefined): string | undefined {
931
+ switch (tone) {
932
+ case "text": return theme.text;
933
+ case "dim": return theme.textDim;
934
+ case "muted": return theme.textMuted;
935
+ case "accent": return theme.accent;
936
+ case "info": return theme.info;
937
+ case "error": return theme.error;
938
+ case "warning": return theme.warning;
939
+ case "success": return theme.success;
940
+ case "borderSubtle": return theme.border;
941
+ case "borderActive": return theme.borderFocused;
942
+ case "panel": return theme.surface;
943
+ case "star": return theme.warning;
944
+ case "logoPrimary": return theme.logoPrimary;
945
+ case "logoSecondary": return theme.logoSecondary;
946
+ case "logoShadow": return theme.logoShadow;
947
+ default: return undefined;
948
+ }
949
+ }
950
+
951
+ export const TimelineRowView = memo(function TimelineRowView({ row }: { row: TimelineRow }) {
952
+ const isActionRow = row.key.includes("-action-");
953
+ renderDebug.useRenderDebug("TimelineRow", {
954
+ rowKey: row.key,
955
+ row,
956
+ });
957
+ if (isActionRow) {
958
+ renderDebug.traceFlickerEvent("timelineRowRender", {
959
+ rowKey: row.key,
960
+ spanToken: row.spans.map((span) => span.text).join("|"),
961
+ });
962
+ }
963
+
964
+ useEffect(() => {
965
+ if (!isActionRow) return;
966
+ renderDebug.traceFlickerEvent("timelineRowMount", { rowKey: row.key });
967
+ renderDebug.traceLifecycleEvent("ActionRow", "mount", { rowKey: row.key });
968
+ renderDebug.traceLifecycleEvent("ActionBlock", "mount", { rowKey: row.key });
969
+ return () => {
970
+ renderDebug.traceFlickerEvent("timelineRowUnmount", { rowKey: row.key });
971
+ renderDebug.traceLifecycleEvent("ActionRow", "unmount", { rowKey: row.key });
972
+ renderDebug.traceLifecycleEvent("ActionBlock", "unmount", { rowKey: row.key });
973
+ };
974
+ }, [isActionRow, row.key]);
975
+
976
+ const theme = useTheme();
977
+
978
+ return (
979
+ <Box width="100%" overflow="hidden">
980
+ <Text>
981
+ {row.spans.map((span, index) => (
982
+ <Text
983
+ key={index}
984
+ color={getToneColor(theme, span.tone)}
985
+ backgroundColor={getToneColor(theme, span.backgroundTone)}
986
+ bold={span.bold}
987
+ >
988
+ {span.text}
989
+ </Text>
990
+ ))}
991
+ </Text>
992
+ </Box>
993
+ );
994
+ }, (prev, next) => prev.row === next.row);
995
+
996
+ function rowArraysEqual(left: TimelineRow[], right: TimelineRow[]): boolean {
997
+ if (left === right) return true;
998
+ if (left.length !== right.length) return false;
999
+ for (let index = 0; index < left.length; index += 1) {
1000
+ if (left[index] !== right[index]) return false;
1001
+ }
1002
+ return true;
1003
+ }
1004
+
1005
+ const TimelineRowsView = memo(function TimelineRowsView({ rows }: { rows: TimelineRow[] }) {
1006
+ return (
1007
+ <>
1008
+ {rows.map((row) => (
1009
+ <TimelineRowView key={row.key} row={row} />
1010
+ ))}
1011
+ </>
1012
+ );
1013
+ }, (prev, next) => rowArraysEqual(prev.rows, next.rows));
1014
+
1015
+ const JumpToBottomBar = memo(function JumpToBottomBar({
1016
+ unseenItems,
1017
+ mouseCapture,
1018
+ scrollPercent,
1019
+ }: { unseenItems: number; mouseCapture: boolean; scrollPercent: number }) {
1020
+ const theme = useTheme();
1021
+ const unseenText = unseenItems > 0 ? ` (${unseenItems} new)` : "";
1022
+ const hintText = mouseCapture ? "wheel↓/End to bottom" : "PageUp/PageDown | End: latest";
1023
+ return (
1024
+ <Box width="100%" paddingX={1}>
1025
+ <Text color={theme.info}>{`History ${scrollPercent}%${unseenText} | ${hintText}`}</Text>
1026
+ </Box>
1027
+ );
1028
+ });
1029
+
1030
+ // ─── Component ────────────────────────────────────────────────────────────────
1031
+
1032
+ export const Timeline = memo(function Timeline({
1033
+ staticEvents,
1034
+ activeEvents,
1035
+ layout,
1036
+ uiState,
1037
+ viewportRows,
1038
+ verboseMode = false,
1039
+ authState = "checking",
1040
+ workspaceLabel = "",
1041
+ workspaceRoot = null,
1042
+ mouseCapture = false,
1043
+ onMouseActivity,
1044
+ contentSized = false,
1045
+ }: TimelineProps) {
1046
+ renderDebug.useRenderDebug("Timeline", {
1047
+ staticEvents,
1048
+ activeEvents,
1049
+ staticEventsLength: staticEvents.length,
1050
+ activeEventsLength: activeEvents.length,
1051
+ cols: layout.cols,
1052
+ rows: layout.rows,
1053
+ mode: layout.mode,
1054
+ uiStateKind: uiState.kind,
1055
+ viewportRows,
1056
+ verboseMode,
1057
+ authState,
1058
+ workspaceLabel,
1059
+ workspaceRoot,
1060
+ });
1061
+ renderDebug.useLifecycleDebug("Timeline", {
1062
+ cols: layout.cols,
1063
+ rows: layout.rows,
1064
+ mode: layout.mode,
1065
+ viewportRows,
1066
+ });
1067
+ renderDebug.traceLayoutValidity("Timeline", {
1068
+ cols: layout.cols,
1069
+ rows: layout.rows,
1070
+ viewportRows,
1071
+ });
1072
+ renderDebug.useFlickerDebug("timelineRender", {
1073
+ staticEvents,
1074
+ activeEvents,
1075
+ staticEventsLength: staticEvents.length,
1076
+ activeEventsLength: activeEvents.length,
1077
+ cols: layout.cols,
1078
+ rows: layout.rows,
1079
+ mode: layout.mode,
1080
+ uiStateKind: uiState.kind,
1081
+ viewportRows,
1082
+ verboseMode,
1083
+ authState,
1084
+ workspaceLabel,
1085
+ });
1086
+ renderDebug.useRenderDebug("Transcript", {
1087
+ staticEvents,
1088
+ activeEvents,
1089
+ staticEventsLength: staticEvents.length,
1090
+ activeEventsLength: activeEvents.length,
1091
+ cols: layout.cols,
1092
+ rows: layout.rows,
1093
+ mode: layout.mode,
1094
+ uiStateKind: uiState.kind,
1095
+ viewportRows,
1096
+ verboseMode,
1097
+ workspaceRoot,
1098
+ });
1099
+
1100
+ const theme = useTheme();
1101
+
1102
+ const staticItems = useMemo(() => buildTimelineItems(staticEvents), [staticEvents]);
1103
+ const activeItems = useMemo(() => buildTimelineItems(activeEvents), [activeEvents]);
1104
+ const activeTurnId = getActiveTurnId(uiState);
1105
+ const runningTurnIds = useMemo(() => getRunningTurnIds(activeEvents), [activeEvents]);
1106
+ const finalizedTurnIds = useMemo(() => getFinalizedTurnIds(staticEvents), [staticEvents]);
1107
+ const finalizedPlanAtTail = useMemo(() => latestFinalizedRunEndsWithPlan(staticEvents), [staticEvents]);
1108
+ const finalizeTransitionRef = useRef<{
1109
+ runningTurnIds: number[];
1110
+ finalizedTurnIds: number[];
1111
+ busy: boolean;
1112
+ }>({
1113
+ runningTurnIds,
1114
+ finalizedTurnIds,
1115
+ busy: isBusyUiState(uiState),
1116
+ });
1117
+ const finalizeTransition = hasFinalizeTransition({
1118
+ previousRunningTurnIds: finalizeTransitionRef.current.runningTurnIds,
1119
+ nextRunningTurnIds: runningTurnIds,
1120
+ nextFinalizedTurnIds: finalizedTurnIds,
1121
+ previousBusy: finalizeTransitionRef.current.busy,
1122
+ nextBusy: isBusyUiState(uiState),
1123
+ });
1124
+ const questionTurnId = uiState.kind === "AWAITING_USER_ACTION" ? uiState.turnId : null;
1125
+ const question = uiState.kind === "AWAITING_USER_ACTION" ? uiState.question : null;
1126
+ const staticTurnIds = useMemo(
1127
+ () => staticItems.filter((item): item is TurnTimelineItem => item.type === "turn").map((item) => item.turnId),
1128
+ [staticItems],
1129
+ );
1130
+ const activeTurnIds = useMemo(
1131
+ () => activeItems.filter((item): item is TurnTimelineItem => item.type === "turn").map((item) => item.turnId),
1132
+ [activeItems],
1133
+ );
1134
+ const allTurnIds = useMemo(
1135
+ () => [...staticTurnIds, ...activeTurnIds],
1136
+ [activeTurnIds, staticTurnIds],
1137
+ );
1138
+ const staticRenderItems = useMemo(
1139
+ () => buildStaticRenderItems(staticItems, allTurnIds, activeTurnId, questionTurnId, question),
1140
+ [activeTurnId, allTurnIds, question, questionTurnId, staticItems],
1141
+ );
1142
+ const activeRenderItems = useMemo(
1143
+ () => buildActiveRenderItems(activeItems, allTurnIds, uiState),
1144
+ [activeItems, allTurnIds, uiState],
1145
+ );
1146
+ // ── Split snapshot building ──────────────────────────────────────────────
1147
+ // During streaming, activeEvents change every frame but staticEvents stay
1148
+ // the same. We further split the active items into "stable" (user prompt,
1149
+ // run header — don't change during streaming) and "streaming" (assistant
1150
+ // content — changes every frame). This gives us three cached tiers so only
1151
+ // the streaming assistant item is rebuilt each frame.
1152
+ const snapshotWidth = Math.max(10, getShellWidth(layout.cols) - 1);
1153
+ const staticSnapshot = useMemo(
1154
+ () => buildTimelineSnapshot(staticRenderItems, { totalWidth: snapshotWidth, verboseMode, debugLabel: "static", workspaceRoot }),
1155
+ [snapshotWidth, staticRenderItems, verboseMode, workspaceRoot],
1156
+ );
1157
+ // Partition active items: non-assistant items are stable during streaming
1158
+ const isStreaming = uiState.kind === "RESPONDING";
1159
+ const activeStableItems = useMemo(
1160
+ () => isStreaming
1161
+ ? activeRenderItems.filter((item) => !(item.type === "turn" && item.item.assistant))
1162
+ : [],
1163
+ [activeRenderItems, isStreaming],
1164
+ );
1165
+ const activeStreamingItems = useMemo(
1166
+ () => isStreaming
1167
+ ? activeRenderItems.filter((item) => item.type === "turn" && item.item.assistant)
1168
+ : activeRenderItems,
1169
+ [activeRenderItems, isStreaming],
1170
+ );
1171
+ const activeStableSnapshot = useMemo(
1172
+ () => STABLE_RENDER_ENABLED
1173
+ ? { items: [], rows: [], totalRows: 0, itemCount: 0 }
1174
+ : activeStableItems.length > 0
1175
+ ? buildTimelineSnapshot(activeStableItems, { totalWidth: snapshotWidth, verboseMode, debugLabel: "active-stable", workspaceRoot })
1176
+ : { items: [], rows: [], totalRows: 0, itemCount: 0 },
1177
+ [snapshotWidth, activeStableItems, verboseMode, workspaceRoot],
1178
+ );
1179
+ const activeStreamingSnapshot = useMemo(
1180
+ () => STABLE_RENDER_ENABLED
1181
+ ? { items: [], rows: [], totalRows: 0, itemCount: 0 }
1182
+ : buildTimelineSnapshot(activeStreamingItems, { totalWidth: snapshotWidth, verboseMode, debugLabel: "active-streaming", workspaceRoot }),
1183
+ [snapshotWidth, activeStreamingItems, verboseMode, workspaceRoot],
1184
+ );
1185
+ const stableActiveSnapshot = useMemo(
1186
+ () => STABLE_RENDER_ENABLED
1187
+ ? buildStableTimelineSnapshot(activeRenderItems, { totalWidth: snapshotWidth, verboseMode, debugLabel: "active-stable-render", workspaceRoot })
1188
+ : null,
1189
+ [snapshotWidth, activeRenderItems, verboseMode, workspaceRoot],
1190
+ );
1191
+ const liveSnapshot = useMemo(
1192
+ () => {
1193
+ if (stableActiveSnapshot) {
1194
+ return {
1195
+ items: [...staticSnapshot.items, ...stableActiveSnapshot.snapshot.items],
1196
+ rows: [...staticSnapshot.rows, ...stableActiveSnapshot.snapshot.rows],
1197
+ totalRows: staticSnapshot.totalRows + stableActiveSnapshot.snapshot.totalRows,
1198
+ itemCount: staticSnapshot.itemCount + stableActiveSnapshot.snapshot.itemCount,
1199
+ };
1200
+ }
1201
+
1202
+ return {
1203
+ items: [...staticSnapshot.items, ...activeStableSnapshot.items, ...activeStreamingSnapshot.items],
1204
+ rows: [...staticSnapshot.rows, ...activeStableSnapshot.rows, ...activeStreamingSnapshot.rows],
1205
+ totalRows: staticSnapshot.totalRows + activeStableSnapshot.totalRows + activeStreamingSnapshot.totalRows,
1206
+ itemCount: staticSnapshot.itemCount + activeStableSnapshot.itemCount + activeStreamingSnapshot.itemCount,
1207
+ };
1208
+ },
1209
+ [staticSnapshot, stableActiveSnapshot, activeStableSnapshot, activeStreamingSnapshot],
1210
+ );
1211
+
1212
+ const lastNonEmptySnapshotRef = useRef<TimelineSnapshot>(liveSnapshot);
1213
+ if (liveSnapshot.totalRows > 0) {
1214
+ lastNonEmptySnapshotRef.current = liveSnapshot;
1215
+ }
1216
+
1217
+ const isBusy = uiState.kind === "THINKING"
1218
+ || uiState.kind === "RESPONDING"
1219
+ || uiState.kind === "ANSWER_VISIBLE"
1220
+ || uiState.kind === "SHELL_RUNNING";
1221
+
1222
+ const transcriptEventCount = staticEvents.length + activeEvents.length;
1223
+
1224
+ const effectiveSnapshot = useMemo(() => {
1225
+ // If we have events but the live snapshot is empty, fallback to the last
1226
+ // known good snapshot to avoid blanking the screen during transitions.
1227
+ if (liveSnapshot.totalRows === 0 && transcriptEventCount > 0 && lastNonEmptySnapshotRef.current.totalRows > 0) {
1228
+ renderDebug.traceFlickerEvent("snapshotFallback", {
1229
+ reason: "empty-while-busy-with-events",
1230
+ uiStateKind: uiState.kind,
1231
+ previousTotalRows: lastNonEmptySnapshotRef.current.totalRows,
1232
+ transcriptEventCount,
1233
+ });
1234
+ return lastNonEmptySnapshotRef.current;
1235
+ }
1236
+ return liveSnapshot;
1237
+ }, [liveSnapshot, transcriptEventCount, uiState.kind]);
1238
+ const snapshotForViewport = effectiveSnapshot;
1239
+
1240
+ const [viewport, setViewport] = useState<TimelineViewportState>(() => createFollowTailViewport(snapshotForViewport.totalRows));
1241
+ const liveSnapshotRef = useRef(snapshotForViewport);
1242
+ // Tracks the previous snapshotWidth so we can detect width changes inside
1243
+ // the liveSnapshot effect and dispatch reflowTimelineViewport instead of
1244
+ // syncTimelineViewport when the terminal has been resized.
1245
+ const snapshotWidthRef = useRef(snapshotWidth);
1246
+ // Tracks previous totalRows so we can skip setViewport when no new rows
1247
+ // arrived — avoiding a second React render / Ink stdout write per streaming
1248
+ // flush when the viewport already reflects the correct state.
1249
+ const prevTotalRowsRef = useRef(effectiveSnapshot.totalRows);
1250
+ // Stable ref so the raw stdin wheel listener always reads the latest
1251
+ // viewportRows without needing to re-register the listener on resize.
1252
+ const viewportRowsRef = useRef(viewportRows);
1253
+
1254
+ const { stdin } = useStdin();
1255
+
1256
+ useEffect(() => {
1257
+ liveSnapshotRef.current = snapshotForViewport;
1258
+ }, [snapshotForViewport]);
1259
+
1260
+ useEffect(() => {
1261
+ viewportRowsRef.current = viewportRows;
1262
+ }, [viewportRows]);
1263
+
1264
+ // Raw stdin listener for SGR mouse wheel events. Ink's readline layer can
1265
+ // fragment escape sequences before they reach useInput, so we parse the raw
1266
+ // bytes directly the same approach BottomComposer uses to detect mouse
1267
+ // events. Only wheel button codes (64/65) are acted upon; clicks are ignored.
1268
+ useEffect(() => {
1269
+ if (!stdin) return;
1270
+
1271
+ function handleRawWheel(chunk: Buffer | string) {
1272
+ const raw = typeof chunk === "string" ? chunk : chunk.toString("utf8");
1273
+ const directions = parseWheelScrollDirections(raw);
1274
+ if (directions.length === 0) return;
1275
+ const delta = directions.reduce(
1276
+ (acc, dir) => acc + (dir === "up" ? -WHEEL_SCROLL_STEP : WHEEL_SCROLL_STEP),
1277
+ 0,
1278
+ );
1279
+ if (delta === 0) return;
1280
+ setViewport((current) =>
1281
+ scrollTimelineViewport(current, liveSnapshotRef.current, viewportRowsRef.current, delta),
1282
+ );
1283
+ onMouseActivity?.();
1284
+ }
1285
+
1286
+ stdin.on("data", handleRawWheel);
1287
+ return () => { stdin.off("data", handleRawWheel); };
1288
+ }, [stdin]);
1289
+
1290
+ useEffect(() => {
1291
+ const widthChanged = snapshotWidthRef.current !== snapshotWidth;
1292
+ snapshotWidthRef.current = snapshotWidth;
1293
+
1294
+ const totalRows = snapshotForViewport.totalRows;
1295
+ const previousTotalRows = prevTotalRowsRef.current;
1296
+ const rowGrowth = totalRows - previousTotalRows;
1297
+ const totalRowsGrew = rowGrowth > 0;
1298
+ prevTotalRowsRef.current = totalRows;
1299
+
1300
+ setViewport((current) => {
1301
+ // Width change: always reflow or sync to wrap text at the new width.
1302
+ if (widthChanged) {
1303
+ const next = !current.followTail && current.frozenSnapshot !== null
1304
+ ? reflowTimelineViewport(current, snapshotForViewport)
1305
+ : syncTimelineViewport(current, snapshotForViewport);
1306
+ renderDebug.traceFlickerEvent("viewportSync", {
1307
+ reason: "width-change",
1308
+ result: next === current ? "skipped" : "updated",
1309
+ previousTotalRows,
1310
+ totalRows,
1311
+ rowGrowth,
1312
+ previousAnchorRow: current.anchorRow,
1313
+ anchorRow: next.anchorRow,
1314
+ followTail: next.followTail,
1315
+ viewportRows,
1316
+ });
1317
+ return next;
1318
+ }
1319
+
1320
+ // Frozen (user scrolled up): keep the frozen snapshot fresh when rows
1321
+ // grow or the stream finalizes so the unseen-item counters and
1322
+ // jump-to-bottom affordance stay accurate.
1323
+ if (!current.followTail) {
1324
+ const next = totalRowsGrew || finalizeTransition
1325
+ ? syncTimelineViewport(current, snapshotForViewport)
1326
+ : current;
1327
+ renderDebug.traceFlickerEvent("viewportSync", {
1328
+ reason: finalizeTransition
1329
+ ? (totalRowsGrew ? "detached-finalize-growth" : "detached-finalize")
1330
+ : (totalRowsGrew ? "detached-growth" : "detached-no-growth"),
1331
+ result: next === current ? "skipped" : "updated",
1332
+ previousTotalRows,
1333
+ totalRows,
1334
+ rowGrowth,
1335
+ previousAnchorRow: current.anchorRow,
1336
+ anchorRow: next.anchorRow,
1337
+ followTail: next.followTail,
1338
+ viewportRows,
1339
+ });
1340
+ return next;
1341
+ }
1342
+
1343
+ // Follow-tail: only advance anchorRow when content actually grew.
1344
+ // Streaming is append-only so shrinking rows is rare; if it happens the
1345
+ // anchorRow stays at the old position until the next growth tick.
1346
+ if (!totalRowsGrew) {
1347
+ renderDebug.traceFlickerEvent("viewportSync", {
1348
+ reason: "follow-tail-no-growth",
1349
+ result: "skipped",
1350
+ previousTotalRows,
1351
+ totalRows,
1352
+ rowGrowth,
1353
+ previousAnchorRow: current.anchorRow,
1354
+ anchorRow: current.anchorRow,
1355
+ followTail: current.followTail,
1356
+ viewportRows,
1357
+ });
1358
+ return current;
1359
+ }
1360
+
1361
+ const useFinalizeContinuity = finalizeTransition
1362
+ && !finalizedPlanAtTail
1363
+ && rowGrowth > Math.max(1, Math.floor(viewportRows / 2));
1364
+ const next = syncTimelineViewport(current, snapshotForViewport, {
1365
+ finalizeContinuity: useFinalizeContinuity
1366
+ ? { previousTotalRows, viewportRows }
1367
+ : undefined,
1368
+ });
1369
+ renderDebug.traceFlickerEvent("viewportSync", {
1370
+ reason: useFinalizeContinuity ? "finalize-continuity" : "follow-tail-growth",
1371
+ result: next === current ? "skipped" : "updated",
1372
+ previousTotalRows,
1373
+ totalRows,
1374
+ rowGrowth,
1375
+ previousAnchorRow: current.anchorRow,
1376
+ anchorRow: next.anchorRow,
1377
+ followTail: next.followTail,
1378
+ viewportRows,
1379
+ });
1380
+ return next;
1381
+ });
1382
+ }, [finalizeTransition, finalizedPlanAtTail, snapshotForViewport, snapshotWidth, viewportRows]);
1383
+
1384
+ useEffect(() => {
1385
+ finalizeTransitionRef.current = {
1386
+ runningTurnIds,
1387
+ finalizedTurnIds,
1388
+ busy: isBusyUiState(uiState),
1389
+ };
1390
+ }, [finalizedTurnIds, runningTurnIds, uiState]);
1391
+
1392
+ const userPromptCount = useMemo(() => {
1393
+ return staticEvents.filter((e) => e.type === "user").length;
1394
+ }, [staticEvents]);
1395
+
1396
+ const prevUserPromptCountRef = useRef(userPromptCount);
1397
+
1398
+ useEffect(() => {
1399
+ if (userPromptCount > prevUserPromptCountRef.current) {
1400
+ setViewport(createFollowTailViewport(snapshotForViewport.totalRows));
1401
+ }
1402
+ prevUserPromptCountRef.current = userPromptCount;
1403
+ }, [userPromptCount, snapshotForViewport.totalRows]);
1404
+
1405
+ useInput((input, key) => {
1406
+ const currentSnapshot = liveSnapshotRef.current;
1407
+ if (currentSnapshot.totalRows === 0) return;
1408
+
1409
+ const rawInput = input.includes("\u001b") ? input : `\u001b${input}`;
1410
+ const wheelDelta = parseTimelineNavigationInput(rawInput).reduce((deltaRows, action) => {
1411
+ if (action === "wheelUp") return deltaRows - WHEEL_SCROLL_STEP;
1412
+ if (action === "wheelDown") return deltaRows + WHEEL_SCROLL_STEP;
1413
+ return deltaRows;
1414
+ }, 0);
1415
+
1416
+ if (wheelDelta !== 0) {
1417
+ setViewport((current) => scrollTimelineViewport(current, currentSnapshot, viewportRows, wheelDelta));
1418
+ return;
1419
+ }
1420
+
1421
+ if (key.pageUp) {
1422
+ setViewport((current) => pageUpTimelineViewport(current, currentSnapshot, viewportRows));
1423
+ return;
1424
+ }
1425
+
1426
+ if (key.pageDown) {
1427
+ setViewport((current) => pageDownTimelineViewport(current, currentSnapshot, viewportRows));
1428
+ return;
1429
+ }
1430
+
1431
+ if (key.ctrl && input === "u") {
1432
+ setViewport((current) => halfPageUpTimelineViewport(current, currentSnapshot, viewportRows));
1433
+ return;
1434
+ }
1435
+
1436
+ if (key.ctrl && input === "d") {
1437
+ setViewport((current) => halfPageDownTimelineViewport(current, currentSnapshot, viewportRows));
1438
+ return;
1439
+ }
1440
+
1441
+ if ((key.meta && key.upArrow) || input === "\u001b\u001b[A" || input === "\u001b[1;3A") {
1442
+ setViewport((current) => stepUpTimelineViewport(current, currentSnapshot, viewportRows));
1443
+ return;
1444
+ }
1445
+
1446
+ if ((key.meta && key.downArrow) || input === "\u001b\u001b[B" || input === "\u001b[1;3B") {
1447
+ setViewport((current) => stepDownTimelineViewport(current, currentSnapshot, viewportRows));
1448
+ return;
1449
+ }
1450
+
1451
+ if (key.home || isHomeInput(input)) {
1452
+ setViewport((current) => homeTimelineViewport(current, currentSnapshot, viewportRows));
1453
+ return;
1454
+ }
1455
+
1456
+ if (key.end || isEndInput(input)) {
1457
+ setViewport(endTimelineViewport(currentSnapshot.totalRows));
1458
+ }
1459
+ });
1460
+
1461
+ const { visibleRows, window: sourceWindow, sourceSnapshot } = useMemo(() => {
1462
+ const selection = selectTimelineRows(snapshotForViewport, viewport, viewportRows);
1463
+ renderDebug.traceEvent("viewport", "slice", {
1464
+ providerState: uiState.kind,
1465
+ visibleRows: selection.visibleRows.length,
1466
+ visibleStart: selection.window.startRow,
1467
+ visibleEnd: selection.window.endRow,
1468
+ anchorRow: selection.window.anchorRow,
1469
+ maxScrollOffset: Math.max(0, selection.sourceSnapshot.totalRows - viewportRows),
1470
+ followTail: viewport.followTail,
1471
+ totalItems: selection.sourceSnapshot.itemCount,
1472
+ totalRows: selection.sourceSnapshot.totalRows,
1473
+ availableTimelineRows: viewportRows,
1474
+ sliceEmpty: selection.visibleRows.length === 0 && selection.sourceSnapshot.totalRows > 0,
1475
+ fallbackSnapshot: snapshotForViewport !== liveSnapshot,
1476
+ });
1477
+ renderDebug.traceFlickerEvent("viewportSlice", {
1478
+ visibleRows: selection.visibleRows.length,
1479
+ startRow: selection.window.startRow,
1480
+ endRow: selection.window.endRow,
1481
+ anchorRow: selection.window.anchorRow,
1482
+ followTail: viewport.followTail,
1483
+ totalRows: selection.sourceSnapshot.totalRows,
1484
+ fallbackSnapshot: snapshotForViewport !== liveSnapshot,
1485
+ });
1486
+ return selection;
1487
+ }, [snapshotForViewport, viewport, viewportRows]);
1488
+ const lastNonEmptyVisibleRowsRef = useRef<TimelineRow[]>([]);
1489
+ if (visibleRows.length > 0) {
1490
+ lastNonEmptyVisibleRowsRef.current = visibleRows;
1491
+ }
1492
+ const usingLastGoodFrameFallback = visibleRows.length === 0 && transcriptEventCount > 0;
1493
+ if (usingLastGoodFrameFallback) {
1494
+ renderDebug.traceEvent("viewport", "lastGoodFrameFallback", {
1495
+ providerState: uiState.kind,
1496
+ totalRows: liveSnapshot.totalRows,
1497
+ availableTimelineRows: viewportRows,
1498
+ preservedRows: lastNonEmptyVisibleRowsRef.current.length,
1499
+ });
1500
+ }
1501
+ const preservedVisibleRows = usingLastGoodFrameFallback
1502
+ ? lastNonEmptyVisibleRowsRef.current
1503
+ : visibleRows;
1504
+
1505
+ const showJumpToBottom = !viewport.followTail;
1506
+ const rowsForDisplay = showJumpToBottom
1507
+ ? preservedVisibleRows.slice(0, Math.max(0, viewportRows - 1))
1508
+ : preservedVisibleRows;
1509
+
1510
+ const totalRows = sourceSnapshot.totalRows;
1511
+ const maxScroll = totalRows - viewportRows;
1512
+ const scrollPercent = maxScroll > 0
1513
+ ? Math.min(100, Math.max(0, Math.round((sourceWindow.startRow / maxScroll) * 100)))
1514
+ : 100;
1515
+
1516
+ if (visibleRows.length === 0) {
1517
+ renderDebug.traceBlankFrame("Timeline", {
1518
+ reason: transcriptEventCount > 0 ? "visible-rows-zero-with-events" : "visible-rows-zero-no-events",
1519
+ staticEventsLength: staticEvents.length,
1520
+ activeEventsLength: activeEvents.length,
1521
+ transcriptEventCount,
1522
+ totalRows: liveSnapshot.totalRows,
1523
+ viewportRows,
1524
+ preservedRows: preservedVisibleRows.length,
1525
+ uiStateKind: uiState.kind,
1526
+ });
1527
+ }
1528
+
1529
+ if (preservedVisibleRows.length === 0) {
1530
+ renderDebug.traceBlankFrame("Timeline", {
1531
+ reason: "empty-root-layout-return",
1532
+ staticEventsLength: staticEvents.length,
1533
+ activeEventsLength: activeEvents.length,
1534
+ transcriptEventCount,
1535
+ totalRows: liveSnapshot.totalRows,
1536
+ effectiveTotalRows: snapshotForViewport.totalRows,
1537
+ viewportRows,
1538
+ uiStateKind: uiState.kind,
1539
+ });
1540
+ return <Box flexDirection="column" width="100%" height={contentSized ? undefined : Math.max(1, viewportRows)} />;
1541
+ }
1542
+
1543
+ const renderScrollbar = () => {
1544
+ const scrollbarHeight = rowsForDisplay.length;
1545
+ if (scrollbarHeight <= 0) return null;
1546
+
1547
+ const total = sourceSnapshot.totalRows;
1548
+ const start = sourceWindow.startRow;
1549
+
1550
+ let thumbHeight = scrollbarHeight;
1551
+ let thumbStart = 0;
1552
+
1553
+ if (total > scrollbarHeight) {
1554
+ thumbHeight = Math.max(1, Math.round((scrollbarHeight / total) * scrollbarHeight));
1555
+ const maxStartRow = total - scrollbarHeight;
1556
+ const maxThumbStart = scrollbarHeight - thumbHeight;
1557
+ thumbStart = maxStartRow > 0 ? Math.round((start / maxStartRow) * maxThumbStart) : 0;
1558
+ thumbStart = Math.max(0, Math.min(maxThumbStart, thumbStart));
1559
+ }
1560
+
1561
+ const scrollbarRows: string[] = [];
1562
+ for (let i = 0; i < scrollbarHeight; i++) {
1563
+ if (i >= thumbStart && i < thumbStart + thumbHeight) {
1564
+ scrollbarRows.push("┃");
1565
+ } else {
1566
+ scrollbarRows.push("│");
1567
+ }
1568
+ }
1569
+
1570
+ return (
1571
+ <Box flexDirection="column" width={1}>
1572
+ {scrollbarRows.map((char, index) => {
1573
+ const isThumb = char === "┃";
1574
+ return (
1575
+ <Text key={index} color={isThumb ? theme.borderFocused : theme.border}>
1576
+ {char}
1577
+ </Text>
1578
+ );
1579
+ })}
1580
+ </Box>
1581
+ );
1582
+ };
1583
+
1584
+ return (
1585
+ <Box
1586
+ flexDirection="column"
1587
+ width="100%"
1588
+ height={contentSized ? undefined : Math.max(1, viewportRows)}
1589
+ overflow="hidden"
1590
+ >
1591
+ <Box flexDirection="row" width="100%" height="100%">
1592
+ <Box flexDirection="column" flexGrow={1}>
1593
+ <TimelineRowsView rows={rowsForDisplay} />
1594
+ </Box>
1595
+ {!contentSized && renderScrollbar()}
1596
+ </Box>
1597
+ {showJumpToBottom && (
1598
+ <JumpToBottomBar
1599
+ unseenItems={viewport.unseenItems}
1600
+ mouseCapture={mouseCapture}
1601
+ scrollPercent={scrollPercent}
1602
+ />
1603
+ )}
1604
+ </Box>
1605
+ );
1606
+ }, (prev, next) => {
1607
+ return (
1608
+ prev.staticEvents === next.staticEvents &&
1609
+ prev.activeEvents === next.activeEvents &&
1610
+ prev.layout.cols === next.layout.cols &&
1611
+ prev.layout.rows === next.layout.rows &&
1612
+ prev.layout.mode === next.layout.mode &&
1613
+ prev.uiState === next.uiState &&
1614
+ prev.viewportRows === next.viewportRows &&
1615
+ prev.verboseMode === next.verboseMode &&
1616
+ prev.authState === next.authState &&
1617
+ prev.workspaceLabel === next.workspaceLabel &&
1618
+ prev.workspaceRoot === next.workspaceRoot &&
1619
+ prev.mouseCapture === next.mouseCapture &&
1620
+ prev.onMouseActivity === next.onMouseActivity &&
1621
+ prev.contentSized === next.contentSized
1622
+ );
1623
+ });
1624
+
1625
+ export { Timeline as ActiveTimeline };