@deepseek-harness-tui/dsh-tui 0.8.7 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/README.md +29 -10
  2. package/bin/dsh-tui.js +1 -1
  3. package/lib/types/commands.d.ts.map +1 -1
  4. package/lib/types/commands.js +4 -2
  5. package/lib/types/components/ActivityPicker.d.ts +4 -1
  6. package/lib/types/components/ActivityPicker.d.ts.map +1 -1
  7. package/lib/types/components/ActivityPicker.js +2 -2
  8. package/lib/types/components/BtwPanel.d.ts.map +1 -1
  9. package/lib/types/components/BtwPanel.js +1 -1
  10. package/lib/types/components/Chat/SubagentMessage.d.ts +2 -1
  11. package/lib/types/components/Chat/SubagentMessage.d.ts.map +1 -1
  12. package/lib/types/components/Chat/SubagentMessage.js +11 -3
  13. package/lib/types/components/CommandSuggestions.d.ts +21 -4
  14. package/lib/types/components/CommandSuggestions.d.ts.map +1 -1
  15. package/lib/types/components/CommandSuggestions.js +41 -14
  16. package/lib/types/components/EffortSlider.d.ts +4 -1
  17. package/lib/types/components/EffortSlider.d.ts.map +1 -1
  18. package/lib/types/components/EffortSlider.js +4 -2
  19. package/lib/types/components/ExtensionDialog.js +6 -2
  20. package/lib/types/components/FileSuggestions.d.ts +26 -5
  21. package/lib/types/components/FileSuggestions.d.ts.map +1 -1
  22. package/lib/types/components/FileSuggestions.js +53 -16
  23. package/lib/types/components/GoalTodoPanel.d.ts +26 -2
  24. package/lib/types/components/GoalTodoPanel.d.ts.map +1 -1
  25. package/lib/types/components/GoalTodoPanel.js +86 -17
  26. package/lib/types/components/HelpMenu.d.ts +7 -1
  27. package/lib/types/components/HelpMenu.d.ts.map +1 -1
  28. package/lib/types/components/HelpMenu.js +12 -4
  29. package/lib/types/components/HistorySearchDialog.d.ts +4 -1
  30. package/lib/types/components/HistorySearchDialog.d.ts.map +1 -1
  31. package/lib/types/components/HistorySearchDialog.js +2 -2
  32. package/lib/types/components/LangPicker.d.ts +14 -0
  33. package/lib/types/components/LangPicker.d.ts.map +1 -0
  34. package/lib/types/components/LangPicker.js +17 -0
  35. package/lib/types/components/LoadedContextPanel.d.ts.map +1 -1
  36. package/lib/types/components/LoadedContextPanel.js +3 -1
  37. package/lib/types/components/LogoV2.d.ts +5 -1
  38. package/lib/types/components/LogoV2.d.ts.map +1 -1
  39. package/lib/types/components/LogoV2.js +12 -3
  40. package/lib/types/components/Markdown.d.ts.map +1 -1
  41. package/lib/types/components/Markdown.js +12 -3
  42. package/lib/types/components/MessageList.d.ts +43 -2
  43. package/lib/types/components/MessageList.d.ts.map +1 -1
  44. package/lib/types/components/MessageList.js +553 -41
  45. package/lib/types/components/ModelPicker.d.ts +4 -1
  46. package/lib/types/components/ModelPicker.d.ts.map +1 -1
  47. package/lib/types/components/ModelPicker.js +2 -2
  48. package/lib/types/components/PermissionsPicker.d.ts +21 -0
  49. package/lib/types/components/PermissionsPicker.d.ts.map +1 -0
  50. package/lib/types/components/PermissionsPicker.js +27 -0
  51. package/lib/types/components/PlanPicker.d.ts +15 -0
  52. package/lib/types/components/PlanPicker.d.ts.map +1 -0
  53. package/lib/types/components/PlanPicker.js +18 -0
  54. package/lib/types/components/PresetPicker.d.ts +4 -1
  55. package/lib/types/components/PresetPicker.d.ts.map +1 -1
  56. package/lib/types/components/PresetPicker.js +2 -2
  57. package/lib/types/components/PromptInput.d.ts +4 -3
  58. package/lib/types/components/PromptInput.d.ts.map +1 -1
  59. package/lib/types/components/PromptInput.js +273 -50
  60. package/lib/types/components/RewindPicker.d.ts +12 -1
  61. package/lib/types/components/RewindPicker.d.ts.map +1 -1
  62. package/lib/types/components/RewindPicker.js +6 -4
  63. package/lib/types/components/ScrollbarGutter.d.ts +28 -0
  64. package/lib/types/components/ScrollbarGutter.d.ts.map +1 -0
  65. package/lib/types/components/ScrollbarGutter.js +68 -0
  66. package/lib/types/components/Select.d.ts +8 -1
  67. package/lib/types/components/Select.d.ts.map +1 -1
  68. package/lib/types/components/Select.js +4 -2
  69. package/lib/types/components/SkillsPicker.d.ts +4 -1
  70. package/lib/types/components/SkillsPicker.d.ts.map +1 -1
  71. package/lib/types/components/SkillsPicker.js +2 -2
  72. package/lib/types/components/SubagentCard.d.ts +2 -1
  73. package/lib/types/components/SubagentCard.d.ts.map +1 -1
  74. package/lib/types/components/SubagentCard.js +6 -1
  75. package/lib/types/components/SubagentDashboard.d.ts +4 -0
  76. package/lib/types/components/SubagentDashboard.d.ts.map +1 -1
  77. package/lib/types/components/SubagentDashboard.js +9 -1
  78. package/lib/types/components/SubagentDetailScene.d.ts.map +1 -1
  79. package/lib/types/components/SubagentDetailScene.js +5 -4
  80. package/lib/types/components/SuggestionCard.d.ts +63 -0
  81. package/lib/types/components/SuggestionCard.d.ts.map +1 -0
  82. package/lib/types/components/SuggestionCard.js +77 -0
  83. package/lib/types/components/ThemePicker.d.ts +4 -1
  84. package/lib/types/components/ThemePicker.d.ts.map +1 -1
  85. package/lib/types/components/ThemePicker.js +2 -2
  86. package/lib/types/components/ThinkingToggle.d.ts +4 -1
  87. package/lib/types/components/ThinkingToggle.d.ts.map +1 -1
  88. package/lib/types/components/ThinkingToggle.js +2 -2
  89. package/lib/types/components/TimelineRail.d.ts +53 -0
  90. package/lib/types/components/TimelineRail.d.ts.map +1 -0
  91. package/lib/types/components/TimelineRail.js +190 -0
  92. package/lib/types/components/WorkspaceFlowPicker.d.ts +6 -1
  93. package/lib/types/components/WorkspaceFlowPicker.d.ts.map +1 -1
  94. package/lib/types/components/WorkspaceFlowPicker.js +4 -2
  95. package/lib/types/components/WorkspaceMenuPicker.d.ts +19 -0
  96. package/lib/types/components/WorkspaceMenuPicker.d.ts.map +1 -0
  97. package/lib/types/components/WorkspaceMenuPicker.js +19 -0
  98. package/lib/types/components/WorkspacePicker.d.ts +4 -1
  99. package/lib/types/components/WorkspacePicker.d.ts.map +1 -1
  100. package/lib/types/components/WorkspacePicker.js +2 -2
  101. package/lib/types/components/approvals/ApprovalPanel.d.ts.map +1 -1
  102. package/lib/types/components/approvals/ApprovalPanel.js +5 -1
  103. package/lib/types/components/design-system/ListItem.d.ts +8 -1
  104. package/lib/types/components/design-system/ListItem.d.ts.map +1 -1
  105. package/lib/types/components/design-system/ListItem.js +8 -3
  106. package/lib/types/components/messages/AssistantTextMessage.d.ts +5 -2
  107. package/lib/types/components/messages/AssistantTextMessage.d.ts.map +1 -1
  108. package/lib/types/components/messages/AssistantTextMessage.js +6 -2
  109. package/lib/types/components/messages/AssistantThinkingMessage.d.ts +2 -1
  110. package/lib/types/components/messages/AssistantThinkingMessage.d.ts.map +1 -1
  111. package/lib/types/components/messages/AssistantThinkingMessage.js +10 -4
  112. package/lib/types/components/messages/AssistantToolUseMessage.d.ts +8 -1
  113. package/lib/types/components/messages/AssistantToolUseMessage.d.ts.map +1 -1
  114. package/lib/types/components/messages/AssistantToolUseMessage.js +4 -2
  115. package/lib/types/components/messages/UserPromptMessage.d.ts +2 -1
  116. package/lib/types/components/messages/UserPromptMessage.d.ts.map +1 -1
  117. package/lib/types/components/questions/AskUserQuestionPanel.d.ts.map +1 -1
  118. package/lib/types/components/questions/AskUserQuestionPanel.js +34 -2
  119. package/lib/types/components/questions/PlanReviewPanel.d.ts.map +1 -1
  120. package/lib/types/components/questions/PlanReviewPanel.js +13 -2
  121. package/lib/types/components/sessions/SessionListRow.d.ts +4 -1
  122. package/lib/types/components/sessions/SessionListRow.d.ts.map +1 -1
  123. package/lib/types/components/sessions/SessionListRow.js +4 -2
  124. package/lib/types/components/trajectory/HotspotView.d.ts +7 -1
  125. package/lib/types/components/trajectory/HotspotView.d.ts.map +1 -1
  126. package/lib/types/components/trajectory/HotspotView.js +8 -2
  127. package/lib/types/components/trajectory/Ledger.d.ts +9 -1
  128. package/lib/types/components/trajectory/Ledger.d.ts.map +1 -1
  129. package/lib/types/components/trajectory/Ledger.js +23 -5
  130. package/lib/types/components/trajectory/WaveBand.d.ts +10 -1
  131. package/lib/types/components/trajectory/WaveBand.d.ts.map +1 -1
  132. package/lib/types/components/trajectory/WaveBand.js +7 -2
  133. package/lib/types/dsh-adapter/channel.d.ts +27 -6
  134. package/lib/types/dsh-adapter/channel.d.ts.map +1 -1
  135. package/lib/types/dsh-adapter/channel.js +432 -66
  136. package/lib/types/dsh-adapter/contract.d.ts +58 -18
  137. package/lib/types/dsh-adapter/contract.d.ts.map +1 -1
  138. package/lib/types/dsh-adapter/contract.js +124 -36
  139. package/lib/types/dsh-adapter/index.d.ts +10 -2
  140. package/lib/types/dsh-adapter/index.d.ts.map +1 -1
  141. package/lib/types/dsh-adapter/index.js +10 -13
  142. package/lib/types/dsh-adapter/plugin.d.ts +0 -9
  143. package/lib/types/dsh-adapter/plugin.d.ts.map +1 -1
  144. package/lib/types/dsh-adapter/plugin.js +136 -6
  145. package/lib/types/i18n.d.ts +194 -16
  146. package/lib/types/i18n.d.ts.map +1 -1
  147. package/lib/types/i18n.js +73 -10
  148. package/lib/types/ink/components/AlternateScreen.d.ts.map +1 -1
  149. package/lib/types/ink/components/AlternateScreen.js +13 -1
  150. package/lib/types/ink/components/App.d.ts +12 -1
  151. package/lib/types/ink/components/App.d.ts.map +1 -1
  152. package/lib/types/ink/components/App.js +101 -4
  153. package/lib/types/ink/components/ClockContext.d.ts.map +1 -1
  154. package/lib/types/ink/components/ClockContext.js +2 -0
  155. package/lib/types/ink/components/ScrollBox.d.ts.map +1 -1
  156. package/lib/types/ink/components/ScrollBox.js +170 -91
  157. package/lib/types/ink/constants.d.ts +9 -0
  158. package/lib/types/ink/constants.d.ts.map +1 -1
  159. package/lib/types/ink/constants.js +9 -0
  160. package/lib/types/ink/events/click-event.d.ts +7 -16
  161. package/lib/types/ink/events/click-event.d.ts.map +1 -1
  162. package/lib/types/ink/events/click-event.js +16 -19
  163. package/lib/types/ink/events/event-handlers.d.ts +11 -1
  164. package/lib/types/ink/events/event-handlers.d.ts.map +1 -1
  165. package/lib/types/ink/events/event-handlers.js +2 -0
  166. package/lib/types/ink/events/input-event.d.ts +9 -0
  167. package/lib/types/ink/events/input-event.d.ts.map +1 -1
  168. package/lib/types/ink/events/input-event.js +4 -0
  169. package/lib/types/ink/events/pointer-event.d.ts +61 -0
  170. package/lib/types/ink/events/pointer-event.d.ts.map +1 -0
  171. package/lib/types/ink/events/pointer-event.js +61 -0
  172. package/lib/types/ink/events/wheel-event.d.ts +17 -0
  173. package/lib/types/ink/events/wheel-event.d.ts.map +1 -0
  174. package/lib/types/ink/events/wheel-event.js +20 -0
  175. package/lib/types/ink/geometry-trace.d.ts +51 -0
  176. package/lib/types/ink/geometry-trace.d.ts.map +1 -0
  177. package/lib/types/ink/geometry-trace.js +83 -0
  178. package/lib/types/ink/hit-test.d.ts +56 -1
  179. package/lib/types/ink/hit-test.d.ts.map +1 -1
  180. package/lib/types/ink/hit-test.js +146 -8
  181. package/lib/types/ink/ink.d.ts +58 -1
  182. package/lib/types/ink/ink.d.ts.map +1 -1
  183. package/lib/types/ink/ink.js +268 -52
  184. package/lib/types/ink/node-cache.d.ts +5 -0
  185. package/lib/types/ink/node-cache.d.ts.map +1 -1
  186. package/lib/types/ink/parse-keypress.d.ts +8 -0
  187. package/lib/types/ink/parse-keypress.d.ts.map +1 -1
  188. package/lib/types/ink/parse-keypress.js +101 -12
  189. package/lib/types/ink/render-node-to-output.d.ts +16 -3
  190. package/lib/types/ink/render-node-to-output.d.ts.map +1 -1
  191. package/lib/types/ink/render-node-to-output.js +144 -25
  192. package/lib/types/ink/selection.d.ts +29 -4
  193. package/lib/types/ink/selection.d.ts.map +1 -1
  194. package/lib/types/ink/selection.js +41 -5
  195. package/lib/types/ink/timeline-rail.d.ts +138 -0
  196. package/lib/types/ink/timeline-rail.d.ts.map +1 -0
  197. package/lib/types/ink/timeline-rail.js +179 -0
  198. package/lib/types/ink/wrap-text.d.ts.map +1 -1
  199. package/lib/types/ink/wrap-text.js +47 -5
  200. package/lib/types/screens/Chat.d.ts.map +1 -1
  201. package/lib/types/screens/Chat.js +819 -337
  202. package/lib/types/screens/SessionBrowser.d.ts.map +1 -1
  203. package/lib/types/screens/SessionBrowser.js +56 -29
  204. package/lib/types/screens/Settings.d.ts.map +1 -1
  205. package/lib/types/screens/Settings.js +64 -22
  206. package/lib/types/screens/StatusLine.d.ts +2 -1
  207. package/lib/types/screens/StatusLine.d.ts.map +1 -1
  208. package/lib/types/screens/StatusLine.js +17 -1
  209. package/lib/types/screens/TrajectoryScene.d.ts.map +1 -1
  210. package/lib/types/screens/TrajectoryScene.js +81 -14
  211. package/lib/types/screens/chatOverlay.d.ts +221 -0
  212. package/lib/types/screens/chatOverlay.d.ts.map +1 -0
  213. package/lib/types/screens/chatOverlay.js +114 -0
  214. package/lib/types/tips.d.ts.map +1 -1
  215. package/lib/types/tips.js +64 -10
  216. package/lib/types/tuiDisplayPrefs.d.ts +15 -0
  217. package/lib/types/tuiDisplayPrefs.d.ts.map +1 -1
  218. package/lib/types/tuiDisplayPrefs.js +9 -0
  219. package/lib/types/utils/debug.d.ts +10 -0
  220. package/lib/types/utils/debug.d.ts.map +1 -1
  221. package/lib/types/utils/debug.js +24 -0
  222. package/lib/types/utils/fileSuggestions.d.ts +19 -0
  223. package/lib/types/utils/fileSuggestions.d.ts.map +1 -0
  224. package/lib/types/utils/fileSuggestions.js +40 -0
  225. package/lib/types/utils/mentions.d.ts.map +1 -1
  226. package/lib/types/utils/mentions.js +3 -1
  227. package/lib/types/utils/paths.d.ts.map +1 -1
  228. package/lib/types/utils/paths.js +1 -0
  229. package/package.json +69 -66
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React from 'react';
2
+ import React, { useState } from 'react';
3
3
  import { t } from '../i18n.js';
4
4
  import { Box, Text, useTerminalSize } from '../ui.js';
5
5
  import { Divider } from './design-system/Divider.js';
@@ -9,6 +9,7 @@ import { AssistantThinkingMessage } from './messages/AssistantThinkingMessage.js
9
9
  import { AssistantToolUseMessage } from './messages/AssistantToolUseMessage.js';
10
10
  import { SubagentMessage } from './Chat/SubagentMessage.js';
11
11
  import { isMinimalMode } from '../minimalMode.js';
12
+ import { noteFrameCause, noteListGeometry } from '../ink/geometry-trace.js';
12
13
  import { InterruptedByUser } from './InterruptedByUser.js';
13
14
  import { LogoV2 } from './LogoV2.js';
14
15
  import { StreamingMarkdown } from './StreamingMarkdown.js';
@@ -16,6 +17,7 @@ import { MessageMetadata } from './messages/MessageMetadata.js';
16
17
  import { stripNarration } from '../utils/narration.js';
17
18
  import { stringWidth } from '../ink/stringWidth.js';
18
19
  import { truncateToWidth } from '../ink/truncateToWidth.js';
20
+ import { clipPreview } from '../ink/timeline-rail.js';
19
21
  /**
20
22
  * Transcript rows rendered in the Claude Code visual language: user prompts
21
23
  * on a grey bubble with a `❯` pointer, assistant text with a `●` bullet and
@@ -25,8 +27,14 @@ import { truncateToWidth } from '../ink/truncateToWidth.js';
25
27
  * rows; `selectedId` highlights the selected row.
26
28
  */
27
29
  /** Render cap for very long sessions (CC's MAX_MESSAGES_WITHOUT_VIRTUALIZATION
28
- * equivalent): older rows fold behind a Divider until Ctrl+E expands them. */
29
- const MAX_RENDERED_ROWS = 300;
30
+ * equivalent): older rows fold behind a Divider until Ctrl+E expands them.
31
+ * 120 (was 300): opening a long session paints the whole cap into the
32
+ * main-screen scrollback (historyPaint), and each row's first markdown
33
+ * lex + wrap costs ~2-5ms — 300 rows saturated the main thread for ~6s
34
+ * on open (measured, 800-row inline session). 120 rows ≈ 4-5 screens of
35
+ * paint (<1s) with the rest behind the show-previous divider (CC parity:
36
+ * the transcript is a viewport, not a printout; load-earlier restores). */
37
+ const MAX_RENDERED_ROWS = 120;
30
38
  // --- layout virtualization constants -------------------------------------
31
39
  // Offscreen rows render as fixed-height spacers whose heights come from the
32
40
  // previous commit's Yoga layout, so the pure-JS Yoga engine never walks
@@ -39,23 +47,163 @@ const DEFAULT_ROW_HEIGHT = 2;
39
47
  /** Cold-start estimate of the header block above the rows; corrected by the
40
48
  * first layout measurement. */
41
49
  const DEFAULT_HEADER_LINES = 14;
42
- export function MessageList({ rows, expanded, expandedRows, selectedId, onToggleRow, model, diffLayout = 'auto', thinkingFold = 'preview', toolBackground = 'none', activityFrames, showAll, onToggleAll, onLoadOlder, thinkingVisible = true, registerRowRef, scrollHandle, forceMountRowId, newSinceRowId, onUnseenCount, failureHintRowId, failureHint, }) {
50
+ /** Stable fallbacks for the stream-fold props: verify/repro harnesses and
51
+ * embedders render MessageList with prop sets that predate them, and the
52
+ * render must not throw (same rule as Chat's stubbed channel APIs). Module
53
+ * scope keeps the identities stable so MemoRow's shallow compare and the
54
+ * toggle callback's deps never churn. */
55
+ const NO_STREAM_FOLDED = new Set();
56
+ const NOOP_TOGGLE_STREAM_FOLD = (_rowId) => { };
57
+ /**
58
+ * Per-kind layout signature PARTS: the O(1) identity of every input that
59
+ * decides a row's rendered HEIGHT (see sigRef in MessageList). Fields are
60
+ * scoped to the row's own renderer — a global flat signature
61
+ * over-invalidates (a diffLayout switch must not drop user-message
62
+ * heights). Text uses length as the proxy: full-text hashing per row per
63
+ * frame would defeat virtualization's budget, and a same-length miss only
64
+ * degrades to the previous behavior.
65
+ *
66
+ * Returns a PARTS VECTOR instead of a joined string: the caller compares
67
+ * slot-by-slot against the cached vector (all primitives), allocating only
68
+ * when a row's inputs actually changed. Building a joined string per row
69
+ * per render was O(rows) allocation churn on every scroll tick — the GC
70
+ * share of the long-session scroll profile.
71
+ *
72
+ * The vector is a MODULE-LEVEL SCRATCH BUFFER: single-threaded synchronous
73
+ * render makes reuse safe, and the unchanged case (the overwhelming
74
+ * majority) allocates nothing. Callers that retain the vector must copy
75
+ * (parts.slice()).
76
+ */
77
+ const signatureScratch = [];
78
+ function signatureParts(row, columns, expanded, expandedRows, streamFolded, thinkingVisible, thinkingFold, diffLayout, model, failureHintRowId, failureHint) {
79
+ signatureScratch.length = 0;
80
+ // Universal height inputs: width reflows every row; kind switches height
81
+ // semantics wholesale; text length drives wrapping.
82
+ signatureScratch.push(columns, row.kind, row.text?.length ?? 0);
83
+ switch (row.kind) {
84
+ case 'assistant':
85
+ // Streaming vs settled swaps renderers; Ctrl+O/per-row expand adds the
86
+ // metadata row (model only renders expanded — keeps an idle /model
87
+ // switch from touching settled rows).
88
+ signatureScratch.push(row.streaming === true, expanded, expandedRows.has(row.id), expanded ? model : '');
89
+ break;
90
+ case 'reasoning':
91
+ // thinkingFold (preview vs full) and the visibility filter change the
92
+ // folded card's height; streaming shows verbose live unless the user
93
+ // clicked it folded (streamFolded collapses to the ticker/header).
94
+ signatureScratch.push(row.streaming === true, expanded, expandedRows.has(row.id), streamFolded.has(row.id), thinkingVisible, thinkingFold);
95
+ break;
96
+ case 'tool': {
97
+ const tool = row.tool;
98
+ signatureScratch.push(expanded, expandedRows.has(row.id), diffLayout, tool?.status ?? '', tool?.resultText?.length ?? 0, tool?.resultFull?.length ?? 0, tool?.errorText?.length ?? 0, row.id === failureHintRowId ? failureHint ?? '' : '');
99
+ break;
100
+ }
101
+ case 'subagent':
102
+ // 卡片高度输入:running→settled 折叠 waterfall+tool 行(5→1 行),
103
+ // failed 增加 error 行。缺这些字段的话 offscreen 结算后 cached
104
+ // height 永不过期 → 滚回 blank band / 滚不到底。
105
+ signatureScratch.push(row.subagent?.status ?? '', row.subagent?.toolCalls.length ?? 0, row.subagent?.outputLines.length ?? 0, row.subagent?.error?.length ?? 0);
106
+ break;
107
+ case 'compact':
108
+ // Folded one-liner vs full summary text.
109
+ signatureScratch.push(expanded, expandedRows.has(row.id));
110
+ break;
111
+ default:
112
+ // user / notice / interrupt / local / local-output: height follows
113
+ // text + columns alone (selection/background never change height).
114
+ break;
115
+ }
116
+ return signatureScratch;
117
+ }
118
+ export function MessageList({ rows, expanded, expandedRows, selectedId, onToggleRow, streamFoldedRows = NO_STREAM_FOLDED, onToggleStreamFold = NOOP_TOGGLE_STREAM_FOLD, model, diffLayout = 'auto', thinkingFold = 'preview', toolBackground = 'none', activityFrames, showAll, onToggleAll, onLoadOlder, thinkingVisible = true, historyPaintEnabled = true, registerRowRef, scrollHandle, forceMountRowId, newSinceRowId, onUnseenCount, onTimeline, failureHintRowId, failureHint, onOpenSubagent, }) {
43
119
  const hiddenCount = rows.length - MAX_RENDERED_ROWS;
44
120
  // The thinking filter runs BEFORE virtualization so window indices line up.
45
- const visibleRows = (showAll || hiddenCount <= 0
46
- ? rows
47
- : rows.slice(hiddenCount)).filter(row => thinkingVisible || row.kind !== 'reasoning');
48
- // CC addMargin: every rendered block gets a 1-row top margin except the
49
- // first. Pre-pass over the FULL list so a windowed row keeps the exact
50
- // spacing it would have in a fully-mounted list.
51
- const margins = new Map();
52
- {
53
- let prev;
54
- for (const row of visibleRows) {
55
- margins.set(row.id, prev !== undefined);
56
- prev = row.kind;
121
+ //
122
+ // Fingerprint memo: every scroll tick re-rendered this pipeline even when
123
+ // nothing changed — slice + filter allocate a fresh rows-length array and
124
+ // the margins pre-pass allocates a Map with one entry per row (3200-row
125
+ // session ⇒ ~200KB churn per 16ms tick, the GC share of the scroll
126
+ // profile). The inputs are all identity-stable across ticks: channel.rows
127
+ // is a live in-place array (identity changes only on rewind/new session),
128
+ // showAll/thinkingVisible are React state. Rows APPENDED in place keep the
129
+ // identity — the cache must key on rows.length too (streaming appends).
130
+ const visibleRowsCacheRef = React.useRef(null);
131
+ /** Generation counter for the visibleRows cache (timeline memo key). */
132
+ const visGenRef = React.useRef(0);
133
+ const visibleCache = visibleRowsCacheRef.current;
134
+ // Streaming-bit fingerprint: in-place `streaming = false` writes (turn
135
+ // settle) are invisible to the rows-identity/length key above, but an
136
+ // assistant row that settles with EMPTY text crosses the empty-assistant
137
+ // filter boundary (visible-while-streaming → filtered-when-settled).
138
+ // Allocation-free scan; rebuild only when a bit actually flipped.
139
+ let streamBitsSame = visibleCache !== null && visibleCache.streamBits.length === rows.length;
140
+ if (streamBitsSame) {
141
+ const bits = visibleCache.streamBits;
142
+ for (let i = 0; i < rows.length; i++) {
143
+ if (bits[i] !== (rows[i].streaming === true ? 1 : 0)) {
144
+ streamBitsSame = false;
145
+ break;
146
+ }
57
147
  }
58
148
  }
149
+ if (visibleCache === null ||
150
+ visibleCache.rows !== rows ||
151
+ visibleCache.rowsLength !== rows.length ||
152
+ visibleCache.showAll !== (showAll || hiddenCount <= 0) ||
153
+ visibleCache.thinkingVisible !== thinkingVisible ||
154
+ !streamBitsSame) {
155
+ const sliced = showAll || hiddenCount <= 0
156
+ ? rows
157
+ : rows.slice(hiddenCount);
158
+ // Empty settled assistant rows (PR #383's duplicate-dot bug): when the
159
+ // model calls a tool without producing text, the assistant/message event
160
+ // carries empty text — rendered as a lone `●` bullet dangling above the
161
+ // tool card. Filter them BEFORE virtualization (not by rendering null in
162
+ // TranscriptRow): a null row never mounts, never enters paintedOnce, and
163
+ // would stall the main-screen history-paint batch loop forever. A row
164
+ // that is STILL STREAMING keeps its place even with empty text — the
165
+ // live dot is the "model is answering" affordance and content may yet
166
+ // arrive.
167
+ let hasEmptyAssistant = false;
168
+ for (const row of sliced) {
169
+ if (row.kind === 'assistant' && row.streaming !== true && (row.text ?? '').trim() === '') {
170
+ hasEmptyAssistant = true;
171
+ break;
172
+ }
173
+ }
174
+ const out = hasEmptyAssistant
175
+ ? sliced.filter(row => !(row.kind === 'assistant' && row.streaming !== true && (row.text ?? '').trim() === '') &&
176
+ (thinkingVisible || row.kind !== 'reasoning'))
177
+ : thinkingVisible
178
+ ? sliced
179
+ : sliced.filter(row => row.kind !== 'reasoning');
180
+ // CC addMargin: every rendered block gets a 1-row top margin except the
181
+ // first. Pre-pass over the FULL list so a windowed row keeps the exact
182
+ // spacing it would have in a fully-mounted list.
183
+ const margins = new Map();
184
+ {
185
+ let prev;
186
+ for (const row of out) {
187
+ margins.set(row.id, prev !== undefined);
188
+ prev = row.kind;
189
+ }
190
+ }
191
+ const streamBits = new Uint8Array(rows.length);
192
+ for (let i = 0; i < rows.length; i++)
193
+ streamBits[i] = rows[i].streaming === true ? 1 : 0;
194
+ visibleRowsCacheRef.current = {
195
+ rows,
196
+ rowsLength: rows.length,
197
+ showAll: showAll || hiddenCount <= 0,
198
+ thinkingVisible,
199
+ out,
200
+ margins,
201
+ streamBits,
202
+ };
203
+ visGenRef.current++;
204
+ }
205
+ const visibleRows = visibleRowsCacheRef.current.out;
206
+ const margins = visibleRowsCacheRef.current.margins;
59
207
  // Selection keeps its highlight; expanded rows render with no fill (the
60
208
  // diff line tints inside cards are the only backgrounds in the transcript).
61
209
  const rowBackground = (rowId) => {
@@ -74,6 +222,9 @@ export function MessageList({ rows, expanded, expandedRows, selectedId, onToggle
74
222
  // DEFAULT_ROW_HEIGHT, which only perturbs deep scrollback estimates.
75
223
  const HEIGHTS_CACHE_MAX = 5000;
76
224
  const heightsRef = React.useRef(new Map());
225
+ /** Geometry version: bumped at EVERY heightsRef mutation so downstream
226
+ * memos (timeline tops) can key on it instead of re-deriving offsets. */
227
+ const heightsVersionRef = React.useRef(0);
77
228
  const localRefs = React.useRef(new Map());
78
229
  /** Row ids that have been mounted (and therefore painted into the
79
230
  * terminal) at least once. The sticky window may skip a row ONLY after
@@ -94,9 +245,20 @@ export function MessageList({ rows, expanded, expandedRows, selectedId, onToggle
94
245
  * free: those rows sit in scrollback and the diff skips them. */
95
246
  const lastStartRef = React.useRef(-1);
96
247
  const holdUntilRef = React.useRef(0);
248
+ /** True when frame-budgeted history painting still has batches left
249
+ * (main-screen open): the layout effect schedules the next slice. */
250
+ const paintPendingRef = React.useRef(false);
251
+ const paintQueuedRef = React.useRef(false);
252
+ /** Persistent history-paint edge: how far batched painting has advanced
253
+ * (index into visibleRows). -1 = not painting / reset (list head change:
254
+ * rewind, new session, loadOlder — must repaint from scratch). */
255
+ const paintEdgeRef = React.useRef(-1);
97
256
  const listHeadId = visibleRows[0]?.id;
98
257
  if (listHeadId !== undefined && paintedBaseRef.current !== undefined && listHeadId !== paintedBaseRef.current) {
99
258
  paintedOnceRef.current = new Set();
259
+ // History repaints from scratch after a head change too (rewind /
260
+ // loadOlder prepends rows that must paint again).
261
+ paintEdgeRef.current = -1;
100
262
  }
101
263
  if (listHeadId !== undefined)
102
264
  paintedBaseRef.current = listHeadId;
@@ -110,8 +272,66 @@ export function MessageList({ rows, expanded, expandedRows, selectedId, onToggle
110
272
  if (lastColumns.current !== columns) {
111
273
  lastColumns.current = columns;
112
274
  heightsRef.current.clear();
275
+ heightsVersionRef.current++;
113
276
  baseRef.current = null;
114
277
  }
278
+ // --- layout signature: stale-height invalidation ------------------------
279
+ // heightsRef entries outlive the commits that measured them, but many
280
+ // state changes rewrite a row's height WITHOUT a columns change: Ctrl+O
281
+ // (expanded), single-row expand (expandedRows), reasoning stream→fold,
282
+ // a tool result/error/footnote arriving, diff layout switch, assistant
283
+ // text growth, thinking visibility. A cached height from before such a
284
+ // change feeds topPad/bottomPad spacers, the offsets scan, and the
285
+ // ScrollBox clamps with geometry that no longer exists — blank bands,
286
+ // overlapping rows, wrong scrollTop after toggles (the audit's stale
287
+ // height cache). Track the inputs that decide each row's height; when
288
+ // one changes, drop the cached height. The window extension further
289
+ // down remounts invalidated rows so useLayoutEffect re-measures them.
290
+ // Text identity uses length as an O(1) proxy — per-frame full-text
291
+ // hashing over every row would defeat virtualization's budget, and a
292
+ // same-length miss only degrades to the previous behavior.
293
+ //
294
+ // VECTORS, not strings: the cache stores the parts VECTOR and the
295
+ // comparison is slot-by-slot — the unchanged case (every settled row on
296
+ // every scroll tick) allocates nothing. A joined string per row per
297
+ // render was O(rows) garbage per tick (3200-row session ⇒ several MB/s
298
+ // into minor GC; the GC share of the scroll profile).
299
+ const sigRef = React.useRef(new Map());
300
+ {
301
+ const sigs = sigRef.current;
302
+ for (let i = 0; i < visibleRows.length; i++) {
303
+ const row = visibleRows[i];
304
+ // Per-kind signature: only the inputs that row's OWN renderer consumes.
305
+ // A global flat array (every field × every row) over-invalidates — a
306
+ // /settings diffLayout switch used to drop every user/assistant/
307
+ // reasoning height at once, remounting the widened window over rows
308
+ // whose rendering never changed (Yoga spike + measure churn for
309
+ // nothing). Base parts cover the universal height inputs.
310
+ const parts = signatureParts(row, columns, expanded, expandedRows, streamFoldedRows, thinkingVisible, thinkingFold, diffLayout, model, failureHintRowId, failureHint);
311
+ const cachedParts = sigs.get(row.id);
312
+ let same = false;
313
+ if (cachedParts !== undefined && cachedParts.length === parts.length) {
314
+ same = true;
315
+ for (let s = 0; s < parts.length; s++) {
316
+ if (parts[s] !== cachedParts[s]) {
317
+ same = false;
318
+ break;
319
+ }
320
+ }
321
+ }
322
+ if (same)
323
+ continue;
324
+ if (sigs.size >= HEIGHTS_CACHE_MAX) {
325
+ const oldest = sigs.keys().next().value;
326
+ if (oldest !== undefined)
327
+ sigs.delete(oldest);
328
+ }
329
+ // Copy out of the module-level scratch buffer — the next row reuses it.
330
+ sigs.set(row.id, parts.slice());
331
+ heightsRef.current.delete(row.id);
332
+ heightsVersionRef.current++;
333
+ }
334
+ }
115
335
  // Scrolling bypasses React (imperative DOM scrollTop): subscribe so the
116
336
  // window follows the viewport.
117
337
  React.useEffect(() => {
@@ -120,8 +340,17 @@ export function MessageList({ rows, expanded, expandedRows, selectedId, onToggle
120
340
  const tick = () => { setScrollTick(t => t + 1); };
121
341
  return scrollHandle.subscribe(tick);
122
342
  }, [scrollHandle]);
343
+ // Cached rail/header preview per user row (see the timeline block for why
344
+ // the length guard exists alongside the id key).
345
+ const previewCacheRef = React.useRef(new Map());
123
346
  const heightOf = (row) => heightsRef.current.get(row.id) ?? DEFAULT_ROW_HEIGHT;
124
- const offsets = new Array(visibleRows.length);
347
+ // Reused offsets buffer: the prefix-scan itself must run every render
348
+ // (heightsRef mutates in the measure effect), but the ARRAY need not be
349
+ // fresh — offsets never escapes this render scope. A new 3200-slot array
350
+ // per scroll tick was pure GC fodder.
351
+ const offsetsBufRef = React.useRef([]);
352
+ const offsets = offsetsBufRef.current;
353
+ offsets.length = visibleRows.length;
125
354
  let total = 0;
126
355
  for (let i = 0; i < visibleRows.length; i++) {
127
356
  offsets[i] = total;
@@ -188,9 +417,87 @@ export function MessageList({ rows, expanded, expandedRows, selectedId, onToggle
188
417
  // vanish from the user's scrollback. Extending mounts everything above
189
418
  // on the first frame (topPad 0, full paint), then the set fills and the
190
419
  // window tightens to the tail.
420
+ // MAIN-SCREEN ONLY (historyPaintEnabled): the alt-screen has no
421
+ // scrollback — a row outside the window has no "copy" to preserve, and
422
+ // mounting the whole fold window on open lexed/highlighted/laid out
423
+ // hundreds of markdown rows the user never sees (measured: 4.3s of
424
+ // saturated main thread before first paint on a 960-row session; the
425
+ // virtualization window re-mounts rows on demand as they scroll in).
426
+ // FRAME-BUDGETED: mounting the whole window in ONE commit saturated the
427
+ // main thread for seconds (measured 6s wall with ZERO frames in the
428
+ // first second on an 800-row inline open — every input queued behind
429
+ // the React render). Extend in batches of ~2 viewports of measured
430
+ // height per commit instead; the pending-batch effect schedules the
431
+ // next slice, so the app paints, drains input, and stays interactive
432
+ // while history streams in above the fold (opencode-style progressive
433
+ // transcript hydration; the terminal accepts rows whenever they land).
191
434
  const paintedOnce = paintedOnceRef.current;
435
+ let paintPending = false;
436
+ if (historyPaintEnabled) {
437
+ // Persistent batch edge: the sticky floor-walk RESETS start to the
438
+ // tail every frame, so a per-frame budget walk from `start` re-spends
439
+ // its whole budget on already-painted rows and never reaches deeper
440
+ // unpainted ones (measured: an infinite 0.3ms/frame batch loop).
441
+ // Remember how far painting has advanced; each batch extends THAT
442
+ // edge upward by the budget.
443
+ if (paintEdgeRef.current < 0)
444
+ paintEdgeRef.current = start;
445
+ let firstUnpainted = -1;
446
+ for (let i = 0; i < paintEdgeRef.current; i++) {
447
+ if (!paintedOnce.has(visibleRows[i].id)) {
448
+ firstUnpainted = i;
449
+ break;
450
+ }
451
+ }
452
+ if (firstUnpainted !== -1) {
453
+ // Budget: extend the paint edge upward by ~half a viewport of
454
+ // measured content per commit (height estimates; unknown rows fall
455
+ // back to DEFAULT_ROW_HEIGHT and over-mount slightly — harmless).
456
+ // Measured: larger batches (2 viewports) put 50ms+ of first-wrap
457
+ // yoga into one frame; half a viewport keeps batches near the
458
+ // 16ms frame budget while still finishing an 800-row open in
459
+ // well under two seconds of background batches.
460
+ let budget = Math.min(viewport, termRows) / 2 + OVERSCAN_LINES;
461
+ let j = paintEdgeRef.current;
462
+ while (j > firstUnpainted && budget > 0) {
463
+ j--;
464
+ budget -= heightOf(visibleRows[j]);
465
+ }
466
+ paintEdgeRef.current = j;
467
+ start = Math.min(start, j);
468
+ paintPending = j > 0;
469
+ }
470
+ }
471
+ else {
472
+ paintEdgeRef.current = -1;
473
+ }
474
+ paintPendingRef.current = paintPending;
475
+ // Unknown-height extension (layout signature, see sigRef): a row whose
476
+ // cached height was just INVALIDATED must remount to re-measure even
477
+ // when it sits outside the window — its spacer otherwise falls back to
478
+ // DEFAULT_ROW_HEIGHT until the row scrolls back into view, leaving the
479
+ // content geometry wrong for exactly that long (blank band after
480
+ // Ctrl+O, unreachable scroll bottom after a tool result lands). One
481
+ // remount per change; the measure tick + hold then tighten again.
482
+ // Guard 1: only rows that have actually MOUNTED here once qualify
483
+ // (paintedOnce fills from localRefs post-commit) — a brand-new
484
+ // streaming row has never been measured, and extending over it would
485
+ // mount everything below the window every frame while the user reads
486
+ // scrolled-up (virtualization defeated, per-frame full mount = the
487
+ // long-session stall). New rows keep the original path: their height
488
+ // lands once the window reaches them.
489
+ // Guard 2: rows actively STREAMING are skipped too. A streaming row's
490
+ // signature invalidates EVERY chunk (text length grows), so a painted
491
+ // streaming row below/above the window remounted per chunk — full
492
+ // markdown re-lex + wrap of the whole growing text, invisible to the
493
+ // user reading history (measured: 3s of streaming while scrolled up
494
+ // burned 2.5s of yoga and +84MB heap). Its height is in flux anyway;
495
+ // the final settle invalidates once more and remounts exactly once.
192
496
  for (let i = 0; i < start; i++) {
193
- if (!paintedOnce.has(visibleRows[i].id)) {
497
+ if (visibleRows[i].streaming === true)
498
+ continue;
499
+ const rowId = visibleRows[i].id;
500
+ if (!heightsRef.current.has(rowId) && paintedOnceRef.current.has(rowId)) {
194
501
  start = i;
195
502
  break;
196
503
  }
@@ -208,6 +515,21 @@ export function MessageList({ rows, expanded, expandedRows, selectedId, onToggle
208
515
  }
209
516
  lastStartRef.current = start;
210
517
  }
518
+ // Tail-side invalidated-height extension (see the start-side loop above
519
+ // for the rationale and both guards): rows BELOW the window whose height
520
+ // was just invalidated remount to re-measure, so bottomPad keeps real
521
+ // geometry while the user reads scrolled-up content and the tail streams.
522
+ // Runs for non-sticky views; sticky mounts the tail anyway. Streaming
523
+ // rows are skipped — THIS loop is the measured hot path of the
524
+ // read-while-streaming stall (the streaming tail row sits below the
525
+ // window and invalidated per chunk).
526
+ for (let i = end; i < visibleRows.length; i++) {
527
+ if (visibleRows[i].streaming === true)
528
+ continue;
529
+ const rowId = visibleRows[i].id;
530
+ if (!heightsRef.current.has(rowId) && paintedOnceRef.current.has(rowId))
531
+ end = i + 1;
532
+ }
211
533
  if (forceMountRowId !== undefined && forceMountRowId !== null) {
212
534
  const idx = visibleRows.findIndex(row => row.id === forceMountRowId);
213
535
  if (idx !== -1) {
@@ -227,6 +549,20 @@ export function MessageList({ rows, expanded, expandedRows, selectedId, onToggle
227
549
  const topPad = offsets[start] ?? 0;
228
550
  const mountedBottom = end < visibleRows.length ? offsets[end] : total;
229
551
  const bottomPad = total - mountedBottom;
552
+ noteListGeometry({
553
+ start,
554
+ end,
555
+ topPad,
556
+ bottomPad,
557
+ total,
558
+ base,
559
+ sticky,
560
+ pending,
561
+ viewport,
562
+ termRows,
563
+ columns,
564
+ rowCount: visibleRows.length,
565
+ });
230
566
  // New-messages pill count: rows past the seen-anchor whose top edge is
231
567
  // still below the viewport bottom. Same rows-space math as the window
232
568
  // (offsets are rows-space, scrollTop content-space — subtract the header
@@ -252,6 +588,131 @@ export function MessageList({ rows, expanded, expandedRows, selectedId, onToggle
252
588
  onUnseenCount?.(unseenCount);
253
589
  }
254
590
  });
591
+ // Conversation timeline snapshot: one entry per user turn (stable id +
592
+ // content-space text top + preview), plus the viewport-derived targets
593
+ // consumed by BOTH the sticky prompt header (active) and the transcript
594
+ // turn rail (active highlight, ▲/▼). Top-anchored semantics (Grok
595
+ // timeline): active = the LAST turn whose prompt top is at-or-above the
596
+ // viewport top — the turn whose content owns the top row, "the turn
597
+ // being read" — with the first turn standing in while pre-turn content
598
+ // (logo / loaded-context) owns the top. The highlight moves only when a
599
+ // turn boundary crosses the viewport top, never when a later prompt
600
+ // merely becomes visible lower on screen, so it cannot leap when
601
+ // nudging off the bottom and the header/rail can never disagree. The
602
+ // projected top (scrollTop + pending) is used so boundary crossings
603
+ // register during wheel bursts, not one drain frame late. Same
604
+ // rows-space math as the mount window (offsets are rows-space,
605
+ // scrollTop content-space — add the header base). downId additionally
606
+ // requires the turn's top ≤ maxScroll: the renderer clamps scrollTop
607
+ // there, so a turn past it could never own the top row, and naming it
608
+ // would make ▼ repeat itself forever (the stuck-▼ bug). Reported
609
+ // post-commit, only when the signature changes.
610
+ let timelineTurns = [];
611
+ let activeTurnIndex = null;
612
+ let upTurnIndex = null;
613
+ let downTurnIndex = null;
614
+ const timelineMemoRef = React.useRef(null);
615
+ {
616
+ // Split into (a) a GEOMETRY-memoized turns list and (b) a per-frame
617
+ // allocation-free target scan. Before the split this block rebuilt on
618
+ // EVERY scroll tick: an 800-object turns array, an 800-entry
619
+ // measuredTops Map, and the report effect's turns.map().join('|')
620
+ // signature — several hundred KB of churn per second of scrolling on a
621
+ // tool-heavy session.
622
+ //
623
+ // (a) turns/tops/folded change ONLY when geometry changes: row heights
624
+ // (heightsVersion — bumped at every heightsRef mutation), the visible
625
+ // window's content (visGen — bumped when the visibleRows cache
626
+ // rebuilds), the measured header base, or the rows array growing. Key
627
+ // on those; previews stay in their own id-keyed cache.
628
+ const memo = timelineMemoRef.current;
629
+ const memoKey = `${visGenRef.current}:${heightsVersionRef.current}:${base}:${rows.length}:${columns}`;
630
+ if (memo === null || memo.key !== memoKey) {
631
+ const previewCache = previewCacheRef.current;
632
+ if (previewCache.size > 2000)
633
+ previewCache.clear();
634
+ // Measured tops for turns INSIDE the fold window (user rows are never
635
+ // filtered by the thinking toggle, so a user row absent from
636
+ // visibleRows is exactly a folded one).
637
+ const measuredTops = new Map();
638
+ for (let i = 0; i < visibleRows.length; i++) {
639
+ const row = visibleRows[i];
640
+ if (row.kind !== 'user')
641
+ continue;
642
+ measuredTops.set(row.id, base + offsets[i] + (margins.get(row.id) === true ? 1 : 0));
643
+ }
644
+ // Walk ALL rows (not the fold window): the rail must cover the whole
645
+ // conversation — a tool-heavy session packs 300 rows into a handful
646
+ // of turns, and window-only turns made the rail show "2-3 nodes".
647
+ // Folded turns carry folded:true + top:-1; their tops are unknown
648
+ // until revealed (they are all ABOVE the viewport, so active/down
649
+ // over measured turns is unaffected; ▲ may name a folded turn — its
650
+ // click goes through the reveal path, not scrollTo).
651
+ const turns = [];
652
+ for (const row of rows) {
653
+ if (row.kind !== 'user')
654
+ continue;
655
+ let cached = previewCache.get(row.id);
656
+ if (cached === undefined || cached.len !== row.text.length) {
657
+ cached = { len: row.text.length, preview: clipPreview(row.text) };
658
+ previewCache.set(row.id, cached);
659
+ }
660
+ const textTop = measuredTops.get(row.id);
661
+ if (textTop !== undefined) {
662
+ turns.push({ id: row.id, top: textTop, preview: cached.preview });
663
+ }
664
+ else {
665
+ turns.push({ id: row.id, top: -1, preview: cached.preview, folded: true });
666
+ }
667
+ }
668
+ timelineMemoRef.current = { key: memoKey, turns };
669
+ }
670
+ timelineTurns = timelineMemoRef.current.turns;
671
+ // (b) per-frame target scan — pure integer comparisons over the
672
+ // memoized list; viewTop is projected (scrollTop + pending) so
673
+ // boundary crossings register during wheel bursts, not one drain
674
+ // frame late. downId additionally requires top ≤ maxScroll: the
675
+ // renderer clamps scrollTop there, so a turn past it could never own
676
+ // the top row, and naming it would make ▼ repeat itself forever.
677
+ const viewTop = scrollTop + pending;
678
+ const maxScroll = Math.max(0, (scrollHandle?.getScrollHeight() ?? 0) - viewport);
679
+ for (let i = 0; i < timelineTurns.length; i++) {
680
+ const t = timelineTurns[i];
681
+ if (t.folded === true) {
682
+ // Above the fold ⇒ strictly above the viewport: a legal ▲ target.
683
+ upTurnIndex = i;
684
+ continue;
685
+ }
686
+ if (t.top <= viewTop)
687
+ activeTurnIndex = i;
688
+ if (t.top < viewTop)
689
+ upTurnIndex = i;
690
+ if (downTurnIndex === null && t.top > viewTop && t.top <= maxScroll) {
691
+ downTurnIndex = i;
692
+ }
693
+ }
694
+ if (timelineTurns.length > 0 && activeTurnIndex === null)
695
+ activeTurnIndex = 0;
696
+ }
697
+ const timeline = {
698
+ turns: timelineTurns,
699
+ activeId: activeTurnIndex === null ? null : timelineTurns[activeTurnIndex].id,
700
+ upId: upTurnIndex === null ? null : timelineTurns[upTurnIndex].id,
701
+ downId: downTurnIndex === null ? null : timelineTurns[downTurnIndex].id,
702
+ };
703
+ const lastTimelineReportRef = React.useRef('');
704
+ React.useEffect(() => {
705
+ // O(1) signature: the memo key pins the turns' geometry identity
706
+ // (heights/window/base/rows-length) and the three ids pin the
707
+ // viewport-derived targets. Any top change bumps the geometry key, so
708
+ // the report still fires exactly when the snapshot content changes —
709
+ // without rebuilding an O(turns) joined string per commit.
710
+ const sig = `${timelineMemoRef.current?.key ?? ''}#a${timeline.activeId}#u${timeline.upId}#d${timeline.downId}`;
711
+ if (sig !== lastTimelineReportRef.current) {
712
+ lastTimelineReportRef.current = sig;
713
+ onTimeline?.(timeline);
714
+ }
715
+ });
255
716
  // Post-commit: measure mounted rows, derive the content-space base from
256
717
  // the first mounted row's Yoga top, and clamp render-time scrollTop to the
257
718
  // mounted coverage so burst scrolls never show blank spacer.
@@ -272,6 +733,7 @@ export function MessageList({ rows, expanded, expandedRows, selectedId, onToggle
272
733
  heightsRef.current.delete(oldest);
273
734
  }
274
735
  heightsRef.current.set(id, h);
736
+ heightsVersionRef.current++;
275
737
  changed = true;
276
738
  }
277
739
  }
@@ -301,7 +763,17 @@ export function MessageList({ rows, expanded, expandedRows, selectedId, onToggle
301
763
  }
302
764
  else {
303
765
  const min = Math.max(0, base + topPad - viewport);
304
- scrollHandle.setClampBounds(min, Math.max(min, base + mountedBottom - viewport));
766
+ // Upper clamp only while unmounted content remains below the window
767
+ // (bottomPad spacer): it exists to pin the paint to the mounted edge
768
+ // during burst scrolls that outrun React's window re-render. Once the
769
+ // tail is fully mounted (bottomPad 0) there IS no unmounted gap — the
770
+ // estimated `mountedBottom` can sit a line short of the real Yoga
771
+ // extent (engine flex-basis cache vs final child layout drift, see
772
+ // render-node-to-output's scrollHeight floor), and an estimated clamp
773
+ // would then cull the last line at every non-sticky paint — the
774
+ // scrolled-away-and-back tail loss. Leave the max open; the renderer
775
+ // still caps at the frame's real maxScroll.
776
+ scrollHandle.setClampBounds(min, bottomPad <= 0 ? undefined : Math.max(min, base + mountedBottom - viewport));
305
777
  }
306
778
  }
307
779
  if (changed && !measureQueuedRef.current) {
@@ -310,9 +782,27 @@ export function MessageList({ rows, expanded, expandedRows, selectedId, onToggle
310
782
  measureQueuedRef.current = true;
311
783
  queueMicrotask(() => {
312
784
  measureQueuedRef.current = false;
785
+ noteFrameCause('measure');
313
786
  setMeasureTick(t => t + 1);
314
787
  });
315
788
  }
789
+ // History-paint continuation (main-screen open): more never-painted
790
+ // batches remain — schedule the next slice on the macrotask queue so
791
+ // pending input events (wheel, keys) drain between batches and the app
792
+ // stays interactive while preset history streams in. A timeout of 0 is
793
+ // enough: each batch's mount+measure work is bounded (~2 viewports),
794
+ // unlike the previous single-commit full mount that saturated the main
795
+ // thread for seconds.
796
+ if (paintPendingRef.current && !paintQueuedRef.current) {
797
+ paintQueuedRef.current = true;
798
+ setTimeout(() => {
799
+ paintQueuedRef.current = false;
800
+ if (!paintPendingRef.current)
801
+ return;
802
+ noteFrameCause('measure');
803
+ setMeasureTick(t => t + 1);
804
+ }, 0);
805
+ }
316
806
  });
317
807
  // useCallback: the reference feeds MemoRow's shallow compare; a fresh
318
808
  // closure per render would defeat every row's memo.
@@ -323,11 +813,7 @@ export function MessageList({ rows, expanded, expandedRows, selectedId, onToggle
323
813
  localRefs.current.delete(rowId);
324
814
  registerRowRef?.(rowId, el);
325
815
  }, [registerRowRef]);
326
- // Second-resolution clock for the running tool card's live elapsed time.
327
- // Computed per render (cheap) but only forwarded to running rows, so
328
- // settled rows never see a changing prop.
329
- const nowSec = Math.floor(Date.now() / 1000);
330
- return (_jsxs(_Fragment, { children: [rows.some(row => row.folded) && (_jsx(Box, { marginTop: 1, onClick: onLoadOlder, children: _jsx(Divider, { title: t('load-earlier') }) })), !showAll && hiddenCount > 0 && (_jsx(Box, { marginTop: 1, onClick: onToggleAll, children: _jsx(Divider, { title: t('show-previous-messages', { n: hiddenCount }) }) })), topPad > 0 && _jsx(Box, { height: topPad, flexShrink: 0 }), visibleRows
816
+ return (_jsxs(_Fragment, { children: [rows.some(row => row.folded) && (_jsx(ClickableDivider, { title: t('load-earlier'), onClick: onLoadOlder })), !showAll && hiddenCount > 0 && (_jsx(ClickableDivider, { title: t('show-previous-messages', { n: hiddenCount }), onClick: onToggleAll })), topPad > 0 && _jsx(Box, { height: topPad, flexShrink: 0 }), visibleRows
331
817
  .slice(start, end)
332
818
  .map((row) => {
333
819
  // CC addMargin: pre-pass result keeps windowed rows at full-mount
@@ -335,30 +821,56 @@ export function MessageList({ rows, expanded, expandedRows, selectedId, onToggle
335
821
  const addMargin = margins.get(row.id) === true;
336
822
  const tool = row.tool;
337
823
  const subagent = row.kind === 'subagent' ? row.subagent : undefined;
338
- return (_jsx(MemoRow, { rowId: row.id, kind: row.kind, text: row.text, executionTarget: row.executionTarget, streaming: row.streaming === true, durationMs: row.durationMs, time: row.time, addMargin: addMargin, isSelected: selectedId === row.id, isExpanded: expandedRows.has(row.id), expanded: expanded, model: model, diffLayout: diffLayout, thinkingFold: thinkingFold, toolBackground: toolBackground, activityFrames: activityFrames, background: rowBackground(row.id), toolCallId: tool?.callId, toolName: tool?.name, toolArgsText: tool?.argsText, toolArgsFull: tool?.argsFull, toolStatus: tool?.status, toolResultText: tool?.resultText, toolResultFull: tool?.resultFull, toolErrorText: tool?.errorText, toolFootnote: failureHintRowId === row.id ? failureHint : undefined, toolCallView: tool?.callView, toolResultView: tool?.resultView, toolStartedAt: tool?.startedAt, toolDurationMs: tool?.durationMs, nowSec: tool?.status === 'running' ? nowSec : undefined, subagent: subagent, onToggleRow: onToggleRow, setRowRef: setRowRef }, row.id));
824
+ return (_jsx(MemoRow, { rowId: row.id, kind: row.kind, text: row.text, executionTarget: row.executionTarget, streaming: row.streaming === true, durationMs: row.durationMs, time: row.time, addMargin: addMargin, isSelected: selectedId === row.id, isExpanded: expandedRows.has(row.id), expanded: expanded, model: model, diffLayout: diffLayout, thinkingFold: thinkingFold, toolBackground: toolBackground, activityFrames: activityFrames, background: rowBackground(row.id), toolCallId: tool?.callId, toolName: tool?.name, toolArgsText: tool?.argsText, toolArgsFull: tool?.argsFull, toolStatus: tool?.status, toolResultText: tool?.resultText, toolResultFull: tool?.resultFull, toolErrorText: tool?.errorText, toolFootnote: failureHintRowId === row.id ? failureHint : undefined, toolCallView: tool?.callView, toolResultView: tool?.resultView, toolStartedAt: tool?.startedAt, toolDurationMs: tool?.durationMs, subagent: subagent, onToggleRow: onToggleRow, onToggleStreamFold: onToggleStreamFold, streamFolded: streamFoldedRows.has(row.id), onOpenSubagent: onOpenSubagent, setRowRef: setRowRef }, row.id));
339
825
  }), bottomPad > 0 && _jsx(Box, { height: bottomPad, flexShrink: 0 })] }));
340
826
  }
341
- function TranscriptRow({ rowId, kind, text, executionTarget, streaming, durationMs, time, addMargin, isSelected, isExpanded, expanded, model, diffLayout, thinkingFold, toolBackground, activityFrames, background, toolCallId, toolName, toolArgsText, toolArgsFull, toolStatus, toolResultText, toolResultFull, toolErrorText, toolFootnote, toolCallView, toolResultView, toolStartedAt, toolDurationMs, subagent, onToggleRow, setRowRef, }) {
827
+ /** Load-earlier / show-previous divider row with a mouse hover tint — the
828
+ * clickability is otherwise invisible (audit C-04). */
829
+ function ClickableDivider({ title, onClick }) {
830
+ const [hovered, setHovered] = useState(false);
831
+ return (_jsx(Box, { marginTop: 1, onClick: onClick, onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), backgroundColor: hovered ? 'userMessageBackgroundHover' : undefined, children: _jsx(Divider, { title: title }) }));
832
+ }
833
+ function TranscriptRow({ rowId, kind, text, executionTarget, streaming, durationMs, time, addMargin, isSelected, isExpanded, expanded, model, diffLayout, thinkingFold, toolBackground, activityFrames, background, toolCallId, toolName, toolArgsText, toolArgsFull, toolStatus, toolResultText, toolResultFull, toolErrorText, toolFootnote, toolCallView, toolResultView, toolStartedAt, toolDurationMs, subagent, onToggleRow, onToggleStreamFold, streamFolded, onOpenSubagent, setRowRef, }) {
342
834
  const ref = React.useCallback((el) => {
343
835
  setRowRef(rowId, el);
344
836
  }, [setRowRef, rowId]);
345
- const onClick = React.useCallback(() => {
837
+ // 可折叠行(工具卡/思考/compact 摘要)共用:点击切换展开,全宽行右侧
838
+ // 空白(屏幕缓冲未写入单元格)不触发——点击空白想选字/拖拽时不再误触
839
+ // 展开/收起(审计 C-03/cellIsBlank 零消费)。纯文本行(user/assistant)
840
+ // 保持不可点:转录是阅读区(用户反馈),折叠语义留给带视觉指示的行。
841
+ const foldOnClick = React.useCallback((event) => {
842
+ if (event.cellIsBlank)
843
+ return;
346
844
  onToggleRow(rowId);
347
845
  }, [onToggleRow, rowId]);
846
+ // 流式 reasoning 行:点击折叠/展开 live 视图(默认展开,与落定行的
847
+ // 默认折叠相反——所以走独立开关,落定后语义自动回到 foldOnClick)。
848
+ const streamFoldOnClick = React.useCallback((event) => {
849
+ if (event.cellIsBlank)
850
+ return;
851
+ onToggleStreamFold(rowId);
852
+ }, [onToggleStreamFold, rowId]);
853
+ // 子代理卡:点击打开详情场景(不是折叠)。
854
+ const openSubagent = React.useCallback(() => {
855
+ if (subagent !== undefined)
856
+ onOpenSubagent?.(subagent.agentId);
857
+ }, [onOpenSubagent, subagent]);
858
+ // compact 摘要折叠行 hover 轻指示(∴ 提亮,不刷背景)。
859
+ const [compactHovered, setCompactHovered] = useState(false);
348
860
  switch (kind) {
349
861
  case 'user':
350
- return (_jsx(Box, { flexDirection: "column", ref: ref, children: _jsx(UserPromptMessage, { text: text, addMargin: addMargin, isSelected: isSelected, onClick: onClick }) }));
862
+ return (_jsx(Box, { flexDirection: "column", ref: ref, children: _jsx(UserPromptMessage, { text: text, addMargin: addMargin, isSelected: isSelected }) }));
351
863
  case 'assistant':
352
- return streaming ? (_jsxs(Box, { alignItems: "flex-start", flexDirection: "row", marginTop: addMargin ? 1 : 0, width: "100%", backgroundColor: background, ref: ref, children: [_jsx(Box, { minWidth: 2, children: _jsx(Text, { color: "text", children: "\u25CF" }) }), _jsx(Box, { flexDirection: "column", children: _jsx(StreamingMarkdown, { children: stripNarration(text) }) })] })) : (_jsxs(Box, { width: "100%", flexDirection: "column", backgroundColor: background, ref: ref, children: [expanded && (_jsx(Box, { flexDirection: "row", justifyContent: "flex-end", gap: 1, marginTop: 1, children: _jsx(MessageMetadata, { timestamp: time, model: model }) })), _jsx(AssistantTextMessage, { text: stripNarration(text), addMargin: addMargin, isSelected: isSelected, isExpanded: isExpanded, onClick: onClick })] }));
864
+ return streaming ? (_jsxs(Box, { alignItems: "flex-start", flexDirection: "row", marginTop: addMargin ? 1 : 0, width: "100%", backgroundColor: background, ref: ref, children: [_jsx(Box, { minWidth: 2, children: _jsx(Text, { color: "text", children: "\u25CF" }) }), _jsx(Box, { flexDirection: "column", children: _jsx(StreamingMarkdown, { children: stripNarration(text) }) })] })) : (_jsxs(Box, { width: "100%", flexDirection: "column", backgroundColor: background, ref: ref, children: [expanded && (_jsx(Box, { flexDirection: "row", justifyContent: "flex-end", gap: 1, marginTop: 1, children: _jsx(MessageMetadata, { timestamp: time, model: model }) })), _jsx(AssistantTextMessage, { text: stripNarration(text), addMargin: addMargin, isSelected: isSelected, isExpanded: isExpanded })] }));
353
865
  case 'reasoning':
354
866
  return (_jsx(Box, { flexDirection: "column", ref: ref, children: _jsx(AssistantThinkingMessage, { thinking: text, addMargin: addMargin, streaming: streaming, preview: streaming &&
867
+ !streamFolded &&
355
868
  thinkingFold === 'preview' &&
356
- !expanded &&
357
869
  !isExpanded,
358
- // Streaming reasoning shows expanded live, then folds
359
- // automatically once the turn settles (unless Ctrl+O or a
360
- // single-row expansion keeps it open).
361
- verbose: isExpanded || expanded || streaming, durationMs: durationMs, isSelected: isSelected, onClick: onClick }) }));
870
+ // Streaming reasoning shows expanded live (click collapses to the
871
+ // ticker/header via streamFolded); settled rows keep the
872
+ // fold-on-settle default and expand via expandedRows/Ctrl+O.
873
+ verbose: isExpanded || expanded || (streaming && !streamFolded), durationMs: durationMs, isSelected: isSelected, onClick: streaming ? streamFoldOnClick : foldOnClick }) }));
362
874
  case 'tool': {
363
875
  if (toolCallId === undefined ||
364
876
  toolName === undefined ||
@@ -383,7 +895,7 @@ function TranscriptRow({ rowId, kind, text, executionTarget, streaming, duration
383
895
  startedAt: toolStartedAt,
384
896
  durationMs: toolDurationMs,
385
897
  };
386
- return (_jsx(Box, { flexDirection: "column", ref: ref, children: _jsx(AssistantToolUseMessage, { tool: tool, addMargin: addMargin, verbose: isExpanded || expanded, isSelected: isSelected, isExpanded: isExpanded, footnote: toolFootnote, diffLayout: diffLayout, toolBackground: toolBackground }) }));
898
+ return (_jsx(Box, { flexDirection: "column", ref: ref, children: _jsx(AssistantToolUseMessage, { tool: tool, addMargin: addMargin, verbose: isExpanded || expanded, isSelected: isSelected, isExpanded: isExpanded, footnote: toolFootnote, diffLayout: diffLayout, toolBackground: toolBackground, onClick: foldOnClick }) }));
387
899
  }
388
900
  case 'notice':
389
901
  return (_jsx(Box, { marginTop: 1, ref: ref, children: _jsx(Divider, { title: ` ${text} ` }) }));
@@ -396,13 +908,13 @@ function TranscriptRow({ rowId, kind, text, executionTarget, streaming, duration
396
908
  return (_jsx(Box, { paddingLeft: 2, backgroundColor: background, ref: ref, children: _jsx(Text, { dimColor: true, children: text }) }));
397
909
  case 'compact':
398
910
  // The post-compaction summary defaults to a folded one-liner with a
399
- // text preview; Ctrl+O (global) or message-selection Enter reveals
400
- // the full summary.
401
- return (_jsx(Box, { marginTop: addMargin ? 1 : 0, paddingLeft: 2, backgroundColor: background, ref: ref, onClick: onClick, children: expanded || isExpanded ? (_jsx(Text, { dimColor: true, children: text })) : (_jsxs(Text, { dimColor: true, italic: true, children: ["\u2234 ", t('compact-summary-folded'), " \u00B7 ", compactPreview(text), ' ', t('hint-expand-ctrl-o')] })) }));
911
+ // text preview; Ctrl+O (global), message-selection Enter, or a click
912
+ // reveals the full summary.
913
+ return (_jsx(Box, { marginTop: addMargin ? 1 : 0, paddingLeft: 2, backgroundColor: background, ref: ref, onClick: foldOnClick, onMouseEnter: () => setCompactHovered(true), onMouseLeave: () => setCompactHovered(false), children: expanded || isExpanded ? (_jsx(Text, { dimColor: true, children: text })) : (_jsxs(Text, { dimColor: true, italic: true, color: compactHovered ? 'text' : undefined, children: [_jsx(Text, { color: compactHovered ? 'text' : undefined, children: "\u2234" }), ' ', t('compact-summary-folded'), " \u00B7 ", compactPreview(text), ' ', t('hint-expand-ctrl-o')] })) }));
402
914
  case 'subagent':
403
915
  if (!subagent)
404
916
  return null;
405
- return (_jsx(Box, { flexDirection: "column", ref: ref, children: _jsx(SubagentMessage, { subagent: subagent, addMargin: addMargin, activityFrames: activityFrames, isExpanded: isExpanded, onClick: () => onToggleRow(rowId) }) }));
917
+ return (_jsx(Box, { flexDirection: "column", ref: ref, children: _jsx(SubagentMessage, { subagent: subagent, addMargin: addMargin, activityFrames: activityFrames, isExpanded: isExpanded, onClick: openSubagent }) }));
406
918
  }
407
919
  }
408
920
  /** Folded compact-summary preview: whitespace flattened, capped with an
@@ -419,10 +931,10 @@ const MemoRow = React.memo(TranscriptRow);
419
931
  * welcome line. It scrolls away with the transcript once the conversation
420
932
  * fills the viewport (Claude Code shows its ✦ logo in the same slot).
421
933
  */
422
- export function LogoHeader({ model, effort, cwd, whale = true, }) {
934
+ export function LogoHeader({ model, effort, cwd, whale = true, skipIntro = false, }) {
423
935
  // Minimal mode drops the whole splash (whale art AND wordmark) — only the
424
936
  // transcript and a bare status bar remain.
425
937
  if (isMinimalMode())
426
938
  return null;
427
- return (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: _jsx(LogoV2, { model: model, effort: effort, cwd: cwd, whale: whale }) }));
939
+ return (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: _jsx(LogoV2, { model: model, effort: effort, cwd: cwd, whale: whale, skipIntro: skipIntro }) }));
428
940
  }