@agi-fans/dsh-tui 0.18.0 → 0.20.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 (227) hide show
  1. package/lib/CHANGELOG.md +115 -1
  2. package/lib/chrome/box.d.ts +2 -0
  3. package/lib/chrome/box.js +6 -12
  4. package/lib/chrome/box.js.map +1 -1
  5. package/lib/chrome/code-highlight.d.ts +26 -18
  6. package/lib/chrome/code-highlight.js +114 -39
  7. package/lib/chrome/code-highlight.js.map +1 -1
  8. package/lib/chrome/diff-render.d.ts +1 -2
  9. package/lib/chrome/diff-render.js +41 -90
  10. package/lib/chrome/diff-render.js.map +1 -1
  11. package/lib/chrome/main-screen-renderer.d.ts +52 -30
  12. package/lib/chrome/main-screen-renderer.js +1730 -79
  13. package/lib/chrome/main-screen-renderer.js.map +1 -1
  14. package/lib/chrome/markdown.js +144 -101
  15. package/lib/chrome/markdown.js.map +1 -1
  16. package/lib/chrome/math.d.ts +4 -0
  17. package/lib/chrome/math.js +340 -0
  18. package/lib/chrome/math.js.map +1 -0
  19. package/lib/chrome/md-style.d.ts +3 -0
  20. package/lib/chrome/md-style.js.map +1 -1
  21. package/lib/chrome/mermaid.d.ts +6 -0
  22. package/lib/chrome/mermaid.js +56 -0
  23. package/lib/chrome/mermaid.js.map +1 -0
  24. package/lib/chrome/renderer.d.ts +78 -0
  25. package/lib/chrome/renderer.js.map +1 -1
  26. package/lib/chrome/status-config.d.ts +5 -0
  27. package/lib/chrome/status-config.js +3 -0
  28. package/lib/chrome/status-config.js.map +1 -1
  29. package/lib/chrome/status-line.d.ts +0 -21
  30. package/lib/chrome/status-line.js +8 -65
  31. package/lib/chrome/status-line.js.map +1 -1
  32. package/lib/chrome/terminal-images.d.ts +31 -0
  33. package/lib/chrome/terminal-images.js +34 -0
  34. package/lib/chrome/terminal-images.js.map +1 -0
  35. package/lib/chrome/theme.d.ts +23 -6
  36. package/lib/chrome/theme.js +217 -109
  37. package/lib/chrome/theme.js.map +1 -1
  38. package/lib/chrome/tool-args.d.ts +24 -1
  39. package/lib/chrome/tool-args.js +40 -1
  40. package/lib/chrome/tool-args.js.map +1 -1
  41. package/lib/chrome/tool-renderers.d.ts +11 -0
  42. package/lib/chrome/tool-renderers.js +55 -5
  43. package/lib/chrome/tool-renderers.js.map +1 -1
  44. package/lib/chrome/welcome-tips.js +2 -2
  45. package/lib/chrome/welcome-tips.js.map +1 -1
  46. package/lib/chrome/width.d.ts +7 -3
  47. package/lib/chrome/width.js +78 -14
  48. package/lib/chrome/width.js.map +1 -1
  49. package/lib/commands/diff.js +49 -3
  50. package/lib/commands/diff.js.map +1 -1
  51. package/lib/commands/export.js +28 -5
  52. package/lib/commands/export.js.map +1 -1
  53. package/lib/commands/files.d.ts +8 -0
  54. package/lib/commands/files.js +137 -0
  55. package/lib/commands/files.js.map +1 -0
  56. package/lib/commands/plugins.d.ts +8 -0
  57. package/lib/commands/plugins.js +158 -0
  58. package/lib/commands/plugins.js.map +1 -0
  59. package/lib/commands/session-configuration.d.ts +4 -0
  60. package/lib/commands/session-configuration.js +39 -8
  61. package/lib/commands/session-configuration.js.map +1 -1
  62. package/lib/commands/session.js +47 -15
  63. package/lib/commands/session.js.map +1 -1
  64. package/lib/commands/steer.js +2 -2
  65. package/lib/commands/steer.js.map +1 -1
  66. package/lib/commands/terminal.d.ts +7 -0
  67. package/lib/commands/terminal.js +99 -0
  68. package/lib/commands/terminal.js.map +1 -0
  69. package/lib/definition.d.ts +180 -14
  70. package/lib/definition.js.map +1 -1
  71. package/lib/input/clipboard.d.ts +12 -7
  72. package/lib/input/clipboard.js +79 -20
  73. package/lib/input/clipboard.js.map +1 -1
  74. package/lib/input/composer-files.d.ts +26 -0
  75. package/lib/input/composer-files.js +50 -0
  76. package/lib/input/composer-files.js.map +1 -0
  77. package/lib/input/editor-discovery.d.ts +21 -0
  78. package/lib/input/editor-discovery.js +99 -0
  79. package/lib/input/editor-discovery.js.map +1 -0
  80. package/lib/input/editor.d.ts +26 -1
  81. package/lib/input/editor.js +126 -12
  82. package/lib/input/editor.js.map +1 -1
  83. package/lib/input/external-editor.d.ts +5 -2
  84. package/lib/input/external-editor.js +14 -12
  85. package/lib/input/external-editor.js.map +1 -1
  86. package/lib/input/fuzzy-search.d.ts +9 -0
  87. package/lib/input/fuzzy-search.js +113 -0
  88. package/lib/input/fuzzy-search.js.map +1 -0
  89. package/lib/input/keybindings-config.d.ts +1 -1
  90. package/lib/input/keybindings-config.js +7 -1
  91. package/lib/input/keybindings-config.js.map +1 -1
  92. package/lib/input/keys.d.ts +17 -0
  93. package/lib/input/keys.js +21 -5
  94. package/lib/input/keys.js.map +1 -1
  95. package/lib/input/mouse-policy.d.ts +3 -0
  96. package/lib/input/mouse-policy.js +14 -0
  97. package/lib/input/mouse-policy.js.map +1 -0
  98. package/lib/input/paste-burst.d.ts +16 -0
  99. package/lib/input/paste-burst.js +61 -0
  100. package/lib/input/paste-burst.js.map +1 -0
  101. package/lib/input/text-selection.d.ts +20 -0
  102. package/lib/input/text-selection.js +195 -0
  103. package/lib/input/text-selection.js.map +1 -0
  104. package/lib/runtime/composer-images.d.ts +1 -0
  105. package/lib/runtime/composer-images.js +11 -3
  106. package/lib/runtime/composer-images.js.map +1 -1
  107. package/lib/runtime/file-open.d.ts +2 -0
  108. package/lib/runtime/file-open.js +12 -0
  109. package/lib/runtime/file-open.js.map +1 -0
  110. package/lib/runtime/file-review.d.ts +24 -0
  111. package/lib/runtime/file-review.js +188 -0
  112. package/lib/runtime/file-review.js.map +1 -0
  113. package/lib/runtime/herdr-agent.d.ts +7 -15
  114. package/lib/runtime/herdr-agent.js +151 -79
  115. package/lib/runtime/herdr-agent.js.map +1 -1
  116. package/lib/runtime/hook-settings.d.ts +17 -0
  117. package/lib/runtime/hook-settings.js +57 -0
  118. package/lib/runtime/hook-settings.js.map +1 -0
  119. package/lib/runtime/human-interaction.js +1 -1
  120. package/lib/runtime/human-interaction.js.map +1 -1
  121. package/lib/runtime/image-preview.d.ts +8 -0
  122. package/lib/runtime/image-preview.js +119 -0
  123. package/lib/runtime/image-preview.js.map +1 -0
  124. package/lib/runtime/plain-tui.d.ts +2 -0
  125. package/lib/runtime/plain-tui.js +2 -2
  126. package/lib/runtime/plain-tui.js.map +1 -1
  127. package/lib/runtime/plugin-settings.d.ts +17 -0
  128. package/lib/runtime/plugin-settings.js +154 -0
  129. package/lib/runtime/plugin-settings.js.map +1 -0
  130. package/lib/runtime/provider-local.d.ts +58 -7
  131. package/lib/runtime/provider-local.js +1394 -137
  132. package/lib/runtime/provider-local.js.map +1 -1
  133. package/lib/runtime/runner.js +3 -3
  134. package/lib/runtime/runner.js.map +1 -1
  135. package/lib/runtime/session-archive.d.ts +5 -0
  136. package/lib/runtime/session-archive.js +114 -0
  137. package/lib/runtime/session-archive.js.map +1 -0
  138. package/lib/runtime/subagent-model-selection.d.ts +5 -0
  139. package/lib/runtime/subagent-model-selection.js +15 -0
  140. package/lib/runtime/subagent-model-selection.js.map +1 -0
  141. package/lib/runtime/terminal-notifications.d.ts +8 -0
  142. package/lib/runtime/terminal-notifications.js +16 -0
  143. package/lib/runtime/terminal-notifications.js.map +1 -1
  144. package/lib/runtime/terminal-reader.d.ts +11 -0
  145. package/lib/runtime/terminal-reader.js +87 -0
  146. package/lib/runtime/terminal-reader.js.map +1 -0
  147. package/lib/runtime/tool-presentation.js +43 -30
  148. package/lib/runtime/tool-presentation.js.map +1 -1
  149. package/lib/session/composer-drafts.d.ts +20 -0
  150. package/lib/session/composer-drafts.js +103 -0
  151. package/lib/session/composer-drafts.js.map +1 -0
  152. package/lib/session/conversation-turns.d.ts +16 -0
  153. package/lib/session/conversation-turns.js +33 -0
  154. package/lib/session/conversation-turns.js.map +1 -0
  155. package/lib/session/feature-toggles.d.ts +79 -0
  156. package/lib/session/feature-toggles.js +139 -0
  157. package/lib/session/feature-toggles.js.map +1 -0
  158. package/lib/session/fold-policy.d.ts +4 -0
  159. package/lib/session/fold-policy.js +4 -0
  160. package/lib/session/fold-policy.js.map +1 -0
  161. package/lib/session/interaction-adapter.d.ts +2 -2
  162. package/lib/session/interaction-adapter.js +156 -25
  163. package/lib/session/interaction-adapter.js.map +1 -1
  164. package/lib/session/job-notice.d.ts +4 -0
  165. package/lib/session/job-notice.js +44 -3
  166. package/lib/session/job-notice.js.map +1 -1
  167. package/lib/session/message-queue.d.ts +22 -0
  168. package/lib/session/message-queue.js +157 -0
  169. package/lib/session/message-queue.js.map +1 -0
  170. package/lib/session/project-file-search.js +3 -21
  171. package/lib/session/project-file-search.js.map +1 -1
  172. package/lib/session/session-controller.d.ts +11 -18
  173. package/lib/session/session-controller.js +246 -100
  174. package/lib/session/session-controller.js.map +1 -1
  175. package/lib/session/session-library.d.ts +11 -3
  176. package/lib/session/session-library.js +48 -8
  177. package/lib/session/session-library.js.map +1 -1
  178. package/lib/session/session-tree.d.ts +26 -0
  179. package/lib/session/session-tree.js +112 -0
  180. package/lib/session/session-tree.js.map +1 -0
  181. package/lib/session/steering.d.ts +3 -0
  182. package/lib/session/steering.js +9 -0
  183. package/lib/session/steering.js.map +1 -0
  184. package/lib/session/tui-settings.d.ts +23 -1
  185. package/lib/session/tui-settings.js +15 -2
  186. package/lib/session/tui-settings.js.map +1 -1
  187. package/lib/session/workflow-projection.d.ts +9 -0
  188. package/lib/session/workflow-projection.js +44 -0
  189. package/lib/session/workflow-projection.js.map +1 -0
  190. package/lib/views/agent-hub.js +9 -4
  191. package/lib/views/agent-hub.js.map +1 -1
  192. package/lib/views/autocomplete.js +5 -25
  193. package/lib/views/autocomplete.js.map +1 -1
  194. package/lib/views/document-reader.d.ts +34 -0
  195. package/lib/views/document-reader.js +145 -0
  196. package/lib/views/document-reader.js.map +1 -0
  197. package/lib/views/event-views.d.ts +26 -2
  198. package/lib/views/event-views.js +196 -42
  199. package/lib/views/event-views.js.map +1 -1
  200. package/lib/views/hotkeys.d.ts +2 -0
  201. package/lib/views/hotkeys.js +20 -8
  202. package/lib/views/hotkeys.js.map +1 -1
  203. package/lib/views/prompt-selector.d.ts +33 -3
  204. package/lib/views/prompt-selector.js +287 -43
  205. package/lib/views/prompt-selector.js.map +1 -1
  206. package/lib/views/prompt-tree.d.ts +11 -0
  207. package/lib/views/prompt-tree.js +74 -0
  208. package/lib/views/prompt-tree.js.map +1 -0
  209. package/lib/views/session-files.d.ts +7 -0
  210. package/lib/views/session-files.js +62 -0
  211. package/lib/views/session-files.js.map +1 -0
  212. package/lib/views/settings-list.d.ts +32 -4
  213. package/lib/views/settings-list.js +148 -37
  214. package/lib/views/settings-list.js.map +1 -1
  215. package/lib/views/transcript-render.d.ts +117 -18
  216. package/lib/views/transcript-render.js +1084 -126
  217. package/lib/views/transcript-render.js.map +1 -1
  218. package/lib/views/transcript-types.d.ts +80 -2
  219. package/lib/views/transcript-types.js +11 -0
  220. package/lib/views/transcript-types.js.map +1 -1
  221. package/lib/views/workspace-changes.d.ts +27 -0
  222. package/lib/views/workspace-changes.js +128 -0
  223. package/lib/views/workspace-changes.js.map +1 -0
  224. package/package.json +77 -41
  225. package/lib/chrome/style.d.ts +0 -22
  226. package/lib/chrome/style.js +0 -23
  227. package/lib/chrome/style.js.map +0 -1
@@ -12,38 +12,24 @@ import { renderCopySelector } from "./copy-selector.js";
12
12
  import { renderSettings } from "./settings-list.js";
13
13
  import { renderTrajectory } from "./trajectory.js";
14
14
  import { renderAgentHub } from "./agent-hub.js";
15
- import { renderPlanReviewPage, renderPromptSelector, renderPromptSelectorPage, } from "./prompt-selector.js";
15
+ import { renderImagePreviewPage, renderPlanReviewPage, renderPromptSelector, renderPromptSelectorPage, renderPromptTreePage, } from "./prompt-selector.js";
16
16
  import { resolveStatusBarConfig } from "../chrome/status-config.js";
17
17
  import { renderPermissionBadge, renderStatusFooter } from "../chrome/status-line.js";
18
- import { createTheme, SPINNER, SYMBOL } from "../chrome/theme.js";
18
+ import { createTheme, paintSurface, SPINNER, SYMBOL, BOX } from "../chrome/theme.js";
19
19
  import { renderGoalBar } from "../chrome/goal-bar.js";
20
20
  import { padToWidth, stripAnsi, truncateToWidth, visibleWidth, wrapText } from "../chrome/width.js";
21
- import { alignFileDiffs, countDiffStats, paintDiffStats, paintPrefixedDiffLine, wrapPaintedDiffRows, } from "../chrome/diff-render.js";
22
- import { renderTool } from "../chrome/tool-renderers.js";
21
+ import { alignFileDiffs, wrapPaintedDiffRows, } from "../chrome/diff-render.js";
22
+ import { workspaceBlockLines } from "./workspace-changes.js";
23
+ import { renderTool, toolArgSubject } from "../chrome/tool-renderers.js";
24
+ import { toolArgsObject } from "../chrome/tool-args.js";
23
25
  import { renderToolsPanel } from "../chrome/tools-list.js";
24
26
  import { renderCommandOutput, renderCommandSeparator } from "../chrome/command-output.js";
25
27
  import { renderPathMentionRows } from "../chrome/path-mentions.js";
26
28
  import { blockMatchesQuery, transcriptSearchHint } from "./transcript-search.js";
27
- import { contentToText, prettyArgs, } from "./transcript-types.js";
29
+ import { contentToText, prettyArgs, reasoningKey, } from "./transcript-types.js";
28
30
  function isBlockPending(block) {
29
31
  return (block.kind === 'assistant' && block.streaming) || (block.kind === 'tool' && block.status === 'running');
30
32
  }
31
- /** Collapsed tool-output preview height (OMP `DEFAULT_TERMINAL_PREVIEW_LINES`). */
32
- export const TOOL_COLLAPSED_LINES = 10;
33
- export const TOOL_INPUT_COLLAPSED_LINES = 3;
34
- function slicePreview(lines, limit, expanded, direction) {
35
- if (expanded || lines.length <= limit)
36
- return { lines: [...lines], hidden: 0 };
37
- const hidden = lines.length - limit;
38
- return {
39
- lines: direction === 'tail' ? lines.slice(hidden) : lines.slice(0, limit),
40
- hidden,
41
- };
42
- }
43
- function toolPreview(lines, width, limit, expanded, direction) {
44
- const contentWidth = Math.max(1, width - 4);
45
- return slicePreview(lines.flatMap(line => wrapText(line, contentWidth)), limit, expanded, direction);
46
- }
47
33
  function exclusiveDiffs(presentation) {
48
34
  if (presentation?.result?.card === 'diff')
49
35
  return presentation.result.diffs;
@@ -53,8 +39,42 @@ function exclusiveDiffs(presentation) {
53
39
  return presentation.call.diffs;
54
40
  return undefined;
55
41
  }
56
- /** OMP AssistantMessage uses one horizontal cell of padding and no vertical padding. */
57
- const ASSISTANT_PADDING_X = 1;
42
+ /**
43
+ * The transcript's one content column.
44
+ *
45
+ * Every block starts its words in the same column: a reply, a prompt after its
46
+ * `›`, a run's header after its `▸`, a notice. The marks own the two cells to
47
+ * the left of it. A reply used to sit one cell in while the marked rows put
48
+ * their words at three, so a screen had four left edges and read as clutter
49
+ * however tidy each block was on its own.
50
+ */
51
+ const ASSISTANT_PADDING_X = 2;
52
+ /** Process summaries keep a readable measure even on wide terminals. */
53
+ const PROCESS_WIDTH = 80;
54
+ function processRoom(width, head) {
55
+ return Math.max(1, Math.min(PROCESS_WIDTH, width - 2) - visibleWidth(head));
56
+ }
57
+ /** Shorten directories before sacrificing the filename. Both separators occur in replayed sessions. */
58
+ function shortPath(path, width) {
59
+ if (visibleWidth(path) <= width)
60
+ return path;
61
+ const parts = path.split(/[/\\]/u);
62
+ if (parts.length < 2)
63
+ return truncateToWidth(path, width);
64
+ const separator = path.includes('\\') ? '\\' : '/';
65
+ const file = parts.pop();
66
+ const tail = '…' + separator + file;
67
+ if (visibleWidth(tail) >= width)
68
+ return truncateToWidth(file, width);
69
+ let prefix = '';
70
+ for (const part of parts) {
71
+ const next = prefix + part + separator;
72
+ if (visibleWidth(next + tail) > width)
73
+ break;
74
+ prefix = next;
75
+ }
76
+ return prefix + tail;
77
+ }
58
78
  function assistantContentLines(lines, width, paddingX) {
59
79
  const margin = ' '.repeat(paddingX);
60
80
  return lines.map((line) => padToWidth(margin + line + margin, width));
@@ -76,13 +96,28 @@ function assistantMarkdown(source, theme, width, style) {
76
96
  return assistantContentLines(rendered, width, paddingX);
77
97
  return assistantContentLines(rendered.map(line => lockThinkingLine(line, theme)), width, paddingX);
78
98
  }
99
+ /**
100
+ * What the reader typed.
101
+ *
102
+ * No mark in front of it. The prompt is the one block whose author is already
103
+ * known — the reader typed it, and it is the row that opens a turn — so it
104
+ * carries its own tint as a slab and reads as a prompt without a glyph
105
+ * claiming what the tint already says. Its words sit on the same column as
106
+ * everything else, so nothing on the screen is held off by a gutter.
107
+ */
79
108
  function userBubble(text, theme, width) {
80
- const inner = Math.max(1, width - 2);
81
- const wrapped = renderPathMentionRows(text, inner, theme);
109
+ const gutter = ' ';
110
+ const wrapped = renderPathMentionRows(text, Math.max(1, width - gutter.length), theme);
82
111
  const rows = ['', ...wrapped, ''];
83
- return rows.map((row) => {
84
- const content = row === '' ? padToWidth('', width) : padToWidth(' ' + row, width);
85
- return theme.colors ? theme.bg('userMessageBg', content) : content;
112
+ // The padding rows share the prompt's tint, so the prompt is one slab that
113
+ // opens the turn. Unpainted, they were two blank rows of their own on top of
114
+ // the gap between blocks, and the prompt floated in twice the space of
115
+ // anything else on the screen.
116
+ return rows.map((row, index) => {
117
+ if (row === '')
118
+ return paintSurface(theme, 'userMessageBg', 'userMessageText', padToWidth('', width));
119
+ const content = padToWidth((index === 1 ? gutter : '') + row, width);
120
+ return paintSurface(theme, 'userMessageBg', 'userMessageText', content);
86
121
  });
87
122
  }
88
123
  function toolIcon(status, theme, spinnerFrame) {
@@ -95,90 +130,367 @@ function toolIcon(status, theme, spinnerFrame) {
95
130
  return theme.fg('success', SYMBOL.success);
96
131
  return theme.fg('error', SYMBOL.error);
97
132
  }
98
- /** Render one tool block as an OMP framed output box. */
99
- function toolBlockLines(block, theme, width, spinnerFrame, expanded) {
100
- const icon = toolIcon(block.status, theme, spinnerFrame);
133
+ /**
134
+ * The gutter mark for a call, or nothing at all.
135
+ *
136
+ * A settled successful call used to carry a checkmark, and a run of five calls
137
+ * put five identical checkmarks in a column — a signal that says only "the row
138
+ * above exists", which it always did. The gutter is for states a reader has to
139
+ * act on: in flight, or wrong. A run of successes is legible by its shape, and
140
+ * the marks that remain mean something.
141
+ */
142
+ function toolStatusMark(status, theme, spinnerFrame) {
143
+ if (status === 'ok')
144
+ return '';
145
+ return toolIcon(status, theme, spinnerFrame);
146
+ }
147
+ /**
148
+ * One-line fact for a failed call.
149
+ *
150
+ * The row has to answer "what failed" on its own, because that is the whole
151
+ * reason the reader is looking at this line. A terminal echoing the command it
152
+ * just ran is not the answer, so it is skipped along with blank lines; whatever
153
+ * comes back is the first thing the tool itself complained about. Falls back to
154
+ * the call's own summary when the output says nothing.
155
+ */
156
+ function toolFailureFact(output, command, fact) {
157
+ for (const line of output.split('\n')) {
158
+ const trimmed = line.trim();
159
+ if (trimmed === '')
160
+ continue;
161
+ if (command !== undefined
162
+ && (trimmed === command || trimmed === `> ${command}` || trimmed === `$ ${command}`))
163
+ continue;
164
+ return trimmed;
165
+ }
166
+ return fact;
167
+ }
168
+ /**
169
+ * A folded call shows its description once, or a kind and subject as fallback.
170
+ * Errors reserve space for their reason; full commands and output stay in the
171
+ * expanded block. Process rows share an 80-cell measure and right padding.
172
+ */
173
+ function toolRowLine(block, theme, width, spinnerFrame) {
101
174
  const presentation = renderTool({
102
175
  name: block.name,
103
176
  arguments: prettyArgs(block.args),
104
177
  output: block.output,
105
178
  status: block.status,
106
- expanded,
179
+ expanded: false,
107
180
  ...(block.partial === true ? { partial: true } : {}),
108
181
  ...(block.presentation === undefined ? {} : { presentation: block.presentation }),
109
182
  });
183
+ // A PTC sub-call ran inside a program, not because the model asked for it
184
+ // directly. Indenting says that without a second visual vocabulary, and the
185
+ // width budget shrinks to match so the row still ends at the same column.
186
+ const nest = block.parentCallId === undefined ? '' : ' ';
187
+ // No gutter column. A settled successful call carries no mark at all — a
188
+ // column of identical bullets says only that the row above exists — and the
189
+ // two that remain are the states a reader has to act on: motion while the
190
+ // call is in flight, and the error mark.
191
+ const mark = block.status === 'ok' ? '' : toolStatusMark(block.status, theme, spinnerFrame) + ' ';
192
+ const head = nest + mark;
193
+ const room = processRoom(width, head);
194
+ const label = processLabel(block.name);
195
+ const subject = toolRowSubject(block, presentation, label);
196
+ const args = toolArgsObject(block.args);
197
+ const call = block.presentation?.call;
198
+ const description = args?.['description'] ?? (call?.card === 'terminal' ? call.description : undefined);
199
+ const descriptionLed = (COMMAND_TOOL.test(block.name) || call?.card === 'terminal')
200
+ && typeof description === 'string' && description.trim() !== '';
201
+ // The echo a failure skips is the command line, which a description-led row
202
+ // no longer carries as its subject.
203
+ const argCommand = args?.['command'];
204
+ const command = typeof argCommand === 'string'
205
+ ? argCommand
206
+ : block.presentation?.call?.card === 'terminal' ? presentation.input[0] ?? subject : subject;
207
+ const fact = block.status === 'error'
208
+ ? firstLineOf(block.job !== undefined && block.job.status !== 'completed'
209
+ ? block.job.detail ?? block.job.status
210
+ : toolFailureFact(block.output, command, presentation.summary ?? '')).replace(/^error:\s*/iu, '')
211
+ : exclusiveDiffs(block.presentation) === undefined ? '' : presentation.summary ?? '';
212
+ // The fact continues the sentence the row already is — `Read file · a.ts ·
213
+ // no such file` — rather than sitting against the right edge, where it read
214
+ // as a separate column that belonged to no row in particular. It is reserved
215
+ // first so a long subject cannot push it off; a failure gets the larger
216
+ // share, because its fact is the reason the reader stopped.
217
+ const factShare = block.status === 'error' ? 0.5 : 1 / 3;
218
+ const factRoom = Math.min(visibleWidth(fact), Math.floor(Math.max(1, room) * factShare));
219
+ const afterFact = Math.max(1, room - (factRoom === 0 ? 0 : factRoom + ROW_JOIN.length));
220
+ const labelRoom = descriptionLed ? 0 : Math.min(visibleWidth(label), afterFact);
221
+ const detailRoom = descriptionLed ? afterFact : afterFact - labelRoom - ROW_JOIN.length;
222
+ const labelText = descriptionLed ? '' : truncateToWidth(label, Math.max(1, labelRoom));
223
+ const pathSubject = ['read', 'write', 'edit'].includes(block.name);
224
+ const detailText = subject === '' || detailRoom < (descriptionLed ? 1 : 8) ? ''
225
+ : pathSubject ? shortPath(subject, detailRoom) : truncateToWidth(subject, detailRoom);
226
+ const tailText = fact === '' || factRoom === 0 ? '' : truncateToWidth(fact, factRoom);
227
+ const text = labelText + (detailText === '' ? '' : (descriptionLed ? '' : ROW_JOIN) + detailText) + (tailText === '' ? '' : ROW_JOIN);
228
+ // A failure is the one line in a run that has to stay findable while scrolling
229
+ // past, so its fact carries the error colour rather than the row's dim.
230
+ const tail = tailText === '' ? '' : block.status === 'error' ? theme.fg('error', tailText) : theme.dim(tailText);
231
+ return [padToWidth(truncateToWidth(head + theme.fg('muted', text) + tail, Math.max(1, width - 2)), width)];
232
+ }
233
+ /** Separator between a process subject and its outcome. */
234
+ const ROW_JOIN = ' · ';
235
+ /** Tools whose `description` argument says what a command was for. */
236
+ const COMMAND_TOOL = /^(bash|shell|pwsh|exec)$/;
237
+ /**
238
+ * What a call acted on, as one line.
239
+ *
240
+ * A shell call carries a `description` written for exactly this purpose, and it
241
+ * reads as intent where the command reads as plumbing: `Show recent commits and
242
+ * status` rather than `cd /long/path && git log --oneline -15 && echo ---`. The
243
+ * command stays reachable by opening the row. Everything else reads its
244
+ * argument fields in the shared order, then any string argument at all — a
245
+ * skill call's only argument is `name` — and finally the card's own title when
246
+ * it says something the label does not.
247
+ */
248
+ function toolRowSubject(block, presentation, label) {
249
+ const args = toolArgsObject(block.args);
250
+ const call = block.presentation?.call;
251
+ if (COMMAND_TOOL.test(block.name) || call?.card === 'terminal') {
252
+ const description = args?.['description'] ?? (call?.card === 'terminal' ? call.description : undefined);
253
+ if (typeof description === 'string' && description.trim() !== '')
254
+ return firstLineOf(description);
255
+ }
256
+ const fromFields = toolArgSubject(block.args, block.partial === true);
257
+ if (fromFields !== '')
258
+ return firstLineOf(fromFields);
259
+ if (args !== undefined) {
260
+ for (const value of Object.values(args)) {
261
+ if (typeof value === 'string' && value.trim() !== '')
262
+ return firstLineOf(value);
263
+ }
264
+ }
265
+ // A card's first input line is the tool's own idea of what the call was — a
266
+ // terminal card puts the command there and its title is only the tool name.
267
+ const fromCard = call === undefined ? '' : firstLineOf(presentation.input[0] ?? '');
268
+ if (fromCard !== '')
269
+ return fromCard;
270
+ const title = presentation.title;
271
+ if (title === undefined || title === block.name)
272
+ return '';
273
+ // `Read src/x.ts` under a `Read file` label would say the verb twice.
274
+ const verb = label.split(' ')[0] ?? '';
275
+ return firstLineOf(verb !== '' && title.startsWith(verb + ' ') ? title.slice(verb.length + 1) : title);
276
+ }
277
+ /** Tool content shared by a running turn and a completed turn opened for reading. */
278
+ function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt+O') {
279
+ const full = mode !== 'preview';
280
+ const presentation = renderTool({
281
+ name: block.name, arguments: prettyArgs(block.args), output: block.output,
282
+ status: block.status, expanded: full,
283
+ ...(block.partial === true ? { partial: true } : {}),
284
+ ...(block.presentation === undefined ? {} : { presentation: block.presentation }),
285
+ });
286
+ const args = toolArgsObject(block.args);
287
+ const call = block.presentation?.call;
288
+ const result = block.presentation?.result;
289
+ const shell = COMMAND_TOOL.test(block.name) || call?.card === 'terminal';
290
+ const read = block.name === 'read' || result?.card === 'read';
110
291
  const diffs = exclusiveDiffs(block.presentation);
111
- if (diffs !== undefined) {
112
- const rows = alignFileDiffs(diffs);
113
- const stats = countDiffStats(rows);
114
- const statsLabel = paintDiffStats(stats.added, stats.removed, theme);
115
- const header = icon + ' ' + theme.bold(presentation.title ?? block.name)
116
- + (statsLabel === '' ? '' : ' ' + statsLabel);
117
- const painted = wrapPaintedDiffRows(rows, theme, width);
118
- const output = slicePreview(painted, TOOL_COLLAPSED_LINES, expanded, 'head');
119
- if (output.hidden > 0) {
120
- output.lines.push(theme.fg('dim', `… ${output.hidden} more lines · ⟨Ctrl+O: Expand⟩`));
292
+ const padding = width > 4 ? 2 : 0;
293
+ const nest = block.parentCallId === undefined || width < 10 ? 0 : 2;
294
+ const room = Math.max(1, width - padding * 2 - nest);
295
+ const subject = toolArgSubject(block.args, block.partial === true)
296
+ || (result?.card === 'read' ? result.path : '')
297
+ || (args === undefined ? '' : Object.values(args).find(value => typeof value === 'string')) || '';
298
+ const command = call?.card === 'terminal' ? call.title
299
+ : typeof args?.['command'] === 'string' ? args['command'] : subject;
300
+ const title = shell ? `$ ${command}`
301
+ : call?.card === 'generic' || call?.card === 'diff' ? call.title
302
+ : !read && presentation.title !== undefined && presentation.title !== block.name ? presentation.title
303
+ : `${block.name}${subject === '' ? '' : ` ${subject}`}`;
304
+ const head = theme.bold(theme.fg(block.status === 'error' ? 'error' : 'toolTitle', title));
305
+ const rows = wrapText(head, room);
306
+ let body = [];
307
+ if (mode === 'details') {
308
+ let input = block.args;
309
+ try {
310
+ input = JSON.stringify(JSON.parse(input), undefined, 2);
311
+ }
312
+ catch { /* Partial arguments retain their received text. */ }
313
+ const output = presentation.output.length > 0 ? presentation.output : block.output.split('\n');
314
+ const outputRows = diffs === undefined
315
+ ? output.flatMap(line => wrapText(theme.fg(block.status === 'error' ? 'error' : 'toolOutput', line), room))
316
+ : wrapPaintedDiffRows(alignFileDiffs(diffs), theme, room);
317
+ body = [
318
+ ...wrapText(theme.fg('dim', 'Input'), room),
319
+ ...input.split('\n').flatMap(line => wrapText(theme.fg('toolOutput', line), room)),
320
+ '', ...wrapText(theme.fg('dim', 'Output'), room),
321
+ ...outputRows,
322
+ ];
323
+ // Keep the tool's rich presentation, but expose received text it replaced.
324
+ if (block.output !== '' && (diffs !== undefined || block.output !== output.join('\n'))) {
325
+ body.push('', ...wrapText(theme.fg('dim', 'Raw result'), room));
326
+ for (const line of block.output.split('\n')) {
327
+ body.push(...wrapText(theme.fg(block.status === 'error' ? 'error' : 'toolOutput', line), room));
328
+ }
121
329
  }
122
- const state = block.status === 'running' ? 'running' : block.status === 'ok' ? 'ok' : 'error';
123
- return renderFramedBlock({ header, state, sections: [{ lines: output.lines }], width }, theme);
124
330
  }
125
- const summary = presentation.summary === undefined || presentation.summary === ''
126
- ? ''
127
- : theme.fg('dim', ' ' + presentation.summary);
128
- const header = icon + ' ' + theme.bold(presentation.title ?? block.name) + summary;
129
- const input = toolPreview(presentation.input, width, TOOL_INPUT_COLLAPSED_LINES, expanded, 'head');
130
- const output = toolPreview(presentation.output, width, TOOL_COLLAPSED_LINES, expanded, presentation.outputPreview);
131
- const hasOutput = presentation.output.length > 0;
132
- const inputLines = input.lines.map(line => (block.presentation?.call?.card === 'diff' ? paintPrefixedDiffLine(line, theme) : theme.fg('toolOutput', line)));
133
- if (input.hidden > 0) {
134
- const hint = hasOutput && output.hidden > 0 ? '' : ' · ⟨Ctrl+O: Expand⟩';
135
- inputLines.push(theme.fg('dim', `… ${input.hidden} more input lines${hint}`));
136
- }
137
- const outputLines = output.lines.map(line => (block.presentation?.result?.card === 'diff' ? paintPrefixedDiffLine(line, theme) : theme.fg('toolOutput', line)));
138
- if (output.hidden > 0) {
139
- const position = presentation.outputPreview === 'tail' ? 'earlier' : 'more';
140
- const hint = theme.fg('dim', `… ${output.hidden} ${position} lines · ⟨Ctrl+O: Expand⟩`);
141
- if (presentation.outputPreview === 'tail')
142
- outputLines.unshift(hint);
331
+ else if (diffs !== undefined) {
332
+ body = wrapPaintedDiffRows(alignFileDiffs(diffs), theme, room);
333
+ }
334
+ else if (full || !read || block.status === 'error') {
335
+ const output = presentation.output.map(line => theme.fg(block.status === 'error' ? 'error' : 'toolOutput', line));
336
+ const input = !shell && !read && (call?.card === 'generic' || (call === undefined && presentation.title !== block.name))
337
+ ? presentation.input.flatMap(line => wrapText(theme.fg('toolOutput', line), room)) : [];
338
+ body = [...input, ...output.flatMap(line => wrapText(line, room))];
339
+ // Writes carry their content in arguments before the result arrives.
340
+ if (body.length === 0 && block.name === 'write' && typeof args?.['content'] === 'string') {
341
+ body = args['content'].split('\n').flatMap(line => wrapText(theme.fg('toolOutput', line), room));
342
+ }
343
+ }
344
+ while (body.length > 0 && stripAnsi(body[body.length - 1]).trim() === '')
345
+ body.pop();
346
+ const limit = full || diffs !== undefined ? body.length : shell ? 5
347
+ : /^(grep|search)$/.test(block.name) ? 15 : /^(glob|find|ls)$/.test(block.name) ? 20 : 10;
348
+ const omitted = Math.max(0, body.length - limit);
349
+ if (omitted > 0) {
350
+ body = shell ? body.slice(-limit) : body.slice(0, limit);
351
+ const shortcut = detailsKey === 'Disabled' ? '' : ` · ${detailsKey}: Expand`;
352
+ const hint = theme.fg('dim', `… ${omitted} ${shell ? 'earlier' : 'more'} lines${shortcut}`);
353
+ if (shell)
354
+ body.unshift(...wrapText(hint, room));
143
355
  else
144
- outputLines.push(hint);
145
- }
146
- const sections = [
147
- ...(inputLines.length === 0 ? [] : [{ lines: inputLines }]),
148
- ...(outputLines.length === 0 ? [] : [{
149
- ...(inputLines.length === 0 ? {} : { label: theme.fg('toolTitle', 'Output') }),
150
- lines: outputLines,
151
- }]),
152
- ];
153
- const state = block.status === 'running' ? 'running' : block.status === 'ok' ? 'ok' : 'error';
154
- return renderFramedBlock({ header, state, sections, width }, theme);
356
+ body.push(...wrapText(hint, room));
357
+ }
358
+ if (!full && read && block.status !== 'error' && block.output !== '' && detailsKey !== 'Disabled') {
359
+ body.push(...wrapText(theme.fg('dim', `${detailsKey}: Expand result`), room));
360
+ }
361
+ if (full && block.status === 'error' && call?.card === 'diff')
362
+ rows.push('', ...wrapPaintedDiffRows(alignFileDiffs(call.diffs), theme, room));
363
+ if (body.length > 0) {
364
+ rows.push('');
365
+ for (const row of body)
366
+ rows.push(row);
367
+ }
368
+ if (block.status === 'error')
369
+ rows.push(...wrapText(theme.fg('error', 'Tool failed'), room));
370
+ if (block.job !== undefined)
371
+ rows.push(...wrapText(theme.fg(block.status === 'error' ? 'error' : 'muted', `Job ${block.job.id} · ${block.job.detail ?? block.job.status}`), room));
372
+ const bg = block.status === 'running' ? 'toolPendingBg' : block.status === 'error' ? 'toolErrorBg' : 'toolSuccessBg';
373
+ return ['', ...rows, ''].map(row => paintSurface(theme, bg, 'toolOutput', padToWidth(' '.repeat(padding + nest) + row, width)));
374
+ }
375
+ function firstLineOf(text) {
376
+ for (const line of text.split('\n')) {
377
+ const trimmed = line.trim();
378
+ if (trimmed !== '')
379
+ return trimmed;
380
+ }
381
+ return '';
382
+ }
383
+ /** Thinking uses the same quiet Markdown style throughout the turn. */
384
+ function openReasoningLines(block, theme, width, mathMode = 'auto') {
385
+ return assistantMarkdown(block.reasoning, theme, width, { color: 'thinkingText', italic: true, mathMode });
386
+ }
387
+ /** The block indexes a run shows as rows, in order: its members, then the thought it took from its answer. */
388
+ function groupMembers(group) {
389
+ const members = [];
390
+ for (let at = group.start; at < (group.answer ?? group.until); at += 1)
391
+ members.push(at);
392
+ if (group.tookThought && group.answer !== undefined)
393
+ members.push(group.answer);
394
+ return members;
155
395
  }
156
396
  /**
157
- * Render one transcript block to display lines. Pure; shared by the tty view
158
- * and the non-tty plain printer.
159
- * @param block - the block to render.
160
- * @param theme - active theme.
161
- * @param width - terminal width in columns.
162
- * @param spinnerFrame - activity spinner phase for running tools.
163
- * @param toolsExpanded - paint full tool output instead of the collapsed preview.
164
- * @returns display lines (already width-fitted).
397
+ * One member of a run, as a row or opened in place.
398
+ *
399
+ * `thoughtOnly` is the step that answered: the run took its thought as the
400
+ * last row and its words are the answer painted below the run, so the run
401
+ * paints the thought and nothing else.
165
402
  */
166
- export function blockLines(block, theme, width, spinnerFrame = 0, toolsExpanded = false) {
403
+ function groupMemberLines(index, context, thoughtOnly = false) {
404
+ const { blocks, options, theme, width } = context;
405
+ const block = blocks[index];
406
+ // Search must reveal a match even when it falls outside a tool's normal preview.
407
+ const full = block.kind === 'tool' && (context.matches.has(index) || options.expandedTools?.has(block.callId) === true);
408
+ const signature = [width, options.colors, context.trueColor, context.themeName, options.mathMode, options.mermaidMode, thoughtOnly, full, options.toolDetailsKey].join('\0');
409
+ const cached = groupMemberCache.get(block);
410
+ let lines;
411
+ if (cached?.signature === signature)
412
+ lines = cached.lines;
413
+ else {
414
+ lines = block.kind === 'tool' ? toolBlockLines(block, theme, width, full ? 'details' : 'preview', options.toolDetailsKey)
415
+ : block.kind === 'assistant' ? thoughtOnly ? openReasoningLines(block, theme, width, options.mathMode)
416
+ : blockLines(block, theme, width, { mathMode: options.mathMode ?? 'auto', mermaidMode: options.mermaidMode ?? 'auto' })
417
+ : block.kind === 'notice' ? blockLines(block, theme, width) : [];
418
+ groupMemberCache.set(block, { signature, lines });
419
+ }
420
+ const search = options.transcriptSearch;
421
+ return search !== undefined && context.matches.has(index)
422
+ ? lines.map(line => blockMatchesQuery(stripAnsi(line), search.query) ? theme.inverse(line) : line)
423
+ : [...lines];
424
+ }
425
+ /** A notice that reports on the run's work, as one of its rows. */
426
+ function processNoticeLine(block, theme, width, indent) {
427
+ const color = block.level === 'error' ? 'error' : 'muted';
428
+ const head = indent + (block.level === 'error' ? theme.fg(color, SYMBOL.error) + ' ' : '');
429
+ const room = processRoom(width, head);
430
+ return padToWidth(truncateToWidth(head + theme.fg(color, truncateToWidth(block.text.split('\n')[0] ?? '', room)), width), width);
431
+ }
432
+ /**
433
+ * Rows of a run live across frames, so they are kept per block the same way a
434
+ * top-level block's rows are: a streamed token re-lays the frame without
435
+ * re-laying every call the run already made.
436
+ */
437
+ const groupMemberCache = new WeakMap();
438
+ /** Flat chronological content, shared by live and opened turns. */
439
+ function processGroupBody(group, context) {
440
+ const lines = [];
441
+ const offsets = new Map();
442
+ for (const index of groupMembers(group)) {
443
+ const rows = groupMemberLines(index, context, index === group.answer && group.tookThought);
444
+ if (rows.length > 0 && lines.length > 0)
445
+ lines.push('');
446
+ offsets.set(index, lines.length);
447
+ for (const row of rows)
448
+ lines.push(row);
449
+ }
450
+ return { lines, offsets };
451
+ }
452
+ /** A settled turn's length, the way a person would say it. */
453
+ export function formatElapsed(ms) {
454
+ const seconds = Math.max(0, Math.round(ms / 1000));
455
+ if (seconds < 60)
456
+ return `${seconds}s`;
457
+ const minutes = Math.floor(seconds / 60);
458
+ if (minutes < 60)
459
+ return `${minutes}m ${seconds % 60}s`;
460
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
461
+ }
462
+ /** A completed turn's duration and any failures remain visible when folded. */
463
+ function processGroupHeader(group, theme, width, elapsed, expandKey = 'Ctrl+O') {
464
+ const label = elapsed === undefined ? 'Worked' : `Worked for ${elapsed}`;
465
+ const failure = group.failures === 0 ? '' : theme.fg('error', ` · ${group.failures} failed`);
466
+ const labelRoom = Math.max(1, width - 4 - visibleWidth(failure));
467
+ const summary = `${SYMBOL.folded} ${theme.fg('dim', truncateToWidth(label, labelRoom))}${failure}`;
468
+ const hint = ` · ${expandKey} to expand`;
469
+ const suffix = expandKey !== 'Disabled' && visibleWidth(summary) + visibleWidth(hint) <= width - 2
470
+ ? theme.fg('dim', hint) : '';
471
+ return [padToWidth(truncateToWidth(summary + suffix, Math.max(1, width - 2)), width)];
472
+ }
473
+ /**
474
+ * Render a block for the transcript or plain printer. Full output is available
475
+ * to direct callers; turn inspection uses previews. omitReasoning excludes
476
+ * thinking already drawn by a run.
477
+ */
478
+ export function blockLines(block, theme, width, { toolsExpanded = false, omitReasoning = false, toolDetailsKey = 'Alt+O', mathMode = 'auto', mermaidMode = 'auto' } = {}) {
167
479
  if (block.kind === 'user')
168
480
  return userBubble(block.text, theme, width);
169
481
  if (block.kind === 'assistant') {
170
482
  const lines = [];
171
- if (block.reasoning !== '') {
172
- lines.push(...assistantMarkdown(block.reasoning, theme, width, { color: 'thinkingText', italic: true }));
483
+ if (block.reasoning !== '' && !omitReasoning) {
484
+ lines.push(...openReasoningLines(block, theme, width, mathMode));
173
485
  if (block.text !== '')
174
486
  lines.push('');
175
487
  }
176
- if (block.text === '' && block.streaming) {
488
+ if (block.text === '' && block.reasoning === '' && block.streaming) {
177
489
  const paddingX = width > ASSISTANT_PADDING_X * 2 ? ASSISTANT_PADDING_X : 0;
178
490
  lines.push(...assistantContentLines([theme.fg('dim', '…')], width, paddingX));
179
491
  }
180
492
  else if (block.text !== '') {
181
- lines.push(...assistantMarkdown(block.text, theme, width, hasExplicitTextColor(theme) ? { color: 'text' } : undefined));
493
+ lines.push(...assistantMarkdown(block.text, theme, width, { ...(hasExplicitTextColor(theme) ? { color: 'text' } : {}), mathMode, mermaidMode }));
182
494
  }
183
495
  if (block.interrupted === true) {
184
496
  const paddingX = width > ASSISTANT_PADDING_X * 2 ? ASSISTANT_PADDING_X : 0;
@@ -186,12 +498,19 @@ export function blockLines(block, theme, width, spinnerFrame = 0, toolsExpanded
186
498
  }
187
499
  return lines;
188
500
  }
189
- if (block.kind === 'tool')
190
- return toolBlockLines(block, theme, width, spinnerFrame, toolsExpanded);
501
+ if (block.kind === 'tool') {
502
+ return toolBlockLines(block, theme, width, toolsExpanded ? 'full' : 'preview', toolDetailsKey);
503
+ }
191
504
  if (block.kind === 'toolCatalog')
192
505
  return renderToolsPanel(block.tools, theme, width, toolsExpanded);
506
+ if (block.kind === 'workspace')
507
+ return workspaceBlockLines(block, theme, width, toolsExpanded);
193
508
  if (block.kind === 'commandOutput')
194
509
  return renderCommandOutput(block.command, block.text, theme, width);
510
+ if (block.boundary === true)
511
+ return [renderTranscriptBoundary(block.text, theme, width)];
512
+ if (block.process !== undefined && block.framed !== true)
513
+ return [processNoticeLine(block, theme, width, '')];
195
514
  if (block.framed !== true) {
196
515
  const prefix = ' ';
197
516
  const continuation = ' '.repeat(visibleWidth(prefix));
@@ -210,16 +529,55 @@ export function blockLines(block, theme, width, spinnerFrame = 0, toolsExpanded
210
529
  applyBg: false,
211
530
  }, theme);
212
531
  }
532
+ /**
533
+ * Seam labels live in one table with the row that draws them, so a later
534
+ * language layer moves the strings in one edit rather than chasing them through
535
+ * the runtime. English until that layer exists.
536
+ */
537
+ const SEAM_LABELS = {
538
+ 'session-opened': 'session opened · earlier output retained',
539
+ cleared: 'transcript cleared · earlier output retained',
540
+ };
541
+ /** The row that separates two documents in the terminal's history. */
542
+ export function transcriptSeam(reason) {
543
+ return { kind: 'notice', level: 'info', text: SEAM_LABELS[reason], boundary: true };
544
+ }
545
+ /**
546
+ * The seam where the transcript was replaced.
547
+ *
548
+ * This row exists because the earlier output is still in the terminal's own
549
+ * scrollback and stays there. Without it, scrolling up walks into rows that
550
+ * describe a document the live view no longer holds and nothing says why. The
551
+ * label names the cause; the rules are chrome, not a message about anything.
552
+ */
553
+ function renderTranscriptBoundary(label, theme, width) {
554
+ if (width <= 0)
555
+ return '';
556
+ const labelText = theme.fg('dim', label);
557
+ const rule = theme.fg('border', BOX.horizontal);
558
+ // A terminal too narrow for a rule on either side still gets the label, since
559
+ // the label is the part that carries the fact.
560
+ const room = width - visibleWidth(label) - 2;
561
+ if (room < 2)
562
+ return truncateToWidth(labelText, Math.max(1, width));
563
+ const left = Math.floor(room / 2);
564
+ return rule.repeat(left) + ' ' + labelText + ' ' + rule.repeat(room - left);
565
+ }
213
566
  /** Plain text of one block, used by transcript search and match painting. */
214
567
  export function blockSearchText(block) {
215
568
  if (block.kind === 'assistant') {
216
569
  return block.reasoning === '' ? block.text : `${block.reasoning}\n${block.text}`;
217
570
  }
218
571
  if (block.kind === 'tool')
219
- return `${block.args}\n${block.output}`;
572
+ return `${block.args}\n${block.output}${block.job === undefined ? '' : `\n${block.job.id} ${block.job.status} ${block.job.detail ?? ''}`}`;
220
573
  if (block.kind === 'toolCatalog') {
221
574
  return block.tools.map(tool => `${tool.name} ${tool.description}`).join('\n');
222
575
  }
576
+ if (block.kind === 'workspace') {
577
+ // Only the served file paths are searchable; a turn whose summary the
578
+ // host no longer holds contributes no path text.
579
+ return block.summary?.files.map(file => file.display).join('\n') ?? `turn ${block.turn}`;
580
+ }
223
581
  return block.text;
224
582
  }
225
583
  function fitFrame(lines, width, stablePrefix = 0) {
@@ -243,32 +601,267 @@ function fitFrame(lines, width, stablePrefix = 0) {
243
601
  const transcriptBodyCache = new WeakMap();
244
602
  /** Settled immutable blocks keep their expensive Markdown/tool layout. */
245
603
  const blockLinesCache = new WeakMap();
246
- function cachedBlockLines(block, options, theme, themeName, trueColor, spinnerFrame, expanded) {
247
- const animatedSpinnerFrame = block.kind === 'tool' && block.status === 'running' ? spinnerFrame : -1;
604
+ function cachedBlockLines(block, options, theme, themeName, trueColor, expanded, omitReasoning = false) {
248
605
  const cached = blockLinesCache.get(block);
249
606
  if (cached !== undefined
250
607
  && cached.width === options.width
251
608
  && cached.colors === options.colors
252
609
  && cached.trueColor === trueColor
253
610
  && cached.themeName === themeName
254
- && cached.spinnerFrame === animatedSpinnerFrame
255
- && cached.expanded === expanded)
611
+ && cached.expanded === expanded
612
+ && cached.omitReasoning === omitReasoning
613
+ && cached.mathMode === options.mathMode
614
+ && cached.mermaidMode === options.mermaidMode
615
+ && cached.toolDetailsKey === options.toolDetailsKey
616
+ && cached.toolDetails === (block.kind === 'tool' && options.expandedTools?.has(block.callId) === true))
256
617
  return cached.lines;
257
- const lines = blockLines(block, theme, options.width, spinnerFrame, expanded);
618
+ const toolDetails = block.kind === 'tool' && options.expandedTools?.has(block.callId) === true;
619
+ const lines = block.kind === 'tool' && toolDetails
620
+ ? toolBlockLines(block, theme, options.width, 'details', options.toolDetailsKey)
621
+ : blockLines(block, theme, options.width, { toolsExpanded: expanded, omitReasoning, toolDetailsKey: options.toolDetailsKey ?? 'Alt+O', mathMode: options.mathMode ?? 'auto', mermaidMode: options.mermaidMode ?? 'auto' });
258
622
  blockLinesCache.set(block, {
623
+ mathMode: options.mathMode,
624
+ mermaidMode: options.mermaidMode,
625
+ toolDetailsKey: options.toolDetailsKey,
626
+ toolDetails,
259
627
  width: options.width,
260
628
  colors: options.colors,
261
629
  trueColor,
262
630
  themeName,
263
- spinnerFrame: animatedSpinnerFrame,
264
631
  expanded,
632
+ omitReasoning,
265
633
  lines,
266
634
  });
267
635
  return lines;
268
636
  }
637
+ /**
638
+ * What a stretch of the transcript was doing, as a short English label.
639
+ *
640
+ * Status strings are English until a language layer exists, so every label a
641
+ * reader can see is resolved from this one table rather than spelled into the
642
+ * rendering code. A tool with no family is reported by its own name, which is
643
+ * still more informative than dropping it.
644
+ */
645
+ const PROCESS_LABELS = [
646
+ { match: /^read$/, label: 'Read file' },
647
+ { match: /^(read_image|readImage)$/, label: 'View image' },
648
+ { match: /^(write|notebook_edit|apply_patch)$/, label: 'Write file' },
649
+ { match: /^(edit|multi_edit|str_replace_editor)$/, label: 'Edit file' },
650
+ { match: /^(grep|glob|list_dir|search)$/, label: 'Search code' },
651
+ { match: /^(bash|shell|pwsh|exec|write_stdin)$/, label: 'Run command' },
652
+ { match: /^(run_code)$/, label: 'Run code' },
653
+ { match: /^(web_search)$/, label: 'Search web' },
654
+ { match: /^(web_fetch)$/, label: 'Fetch page' },
655
+ {
656
+ match: /^(subagent|subagent_fork|subagent_isolated|agent|ralph|workflow_run)$/, label: 'Run subagent',
657
+ },
658
+ {
659
+ match: /^(session_search|session_trace|session_event_read|session_event_search|session_event_trace)$/,
660
+ label: 'Search sessions',
661
+ },
662
+ {
663
+ match: /^(todo_write|update_goal|create_goal|get_goal)$/, label: 'Update plan',
664
+ },
665
+ {
666
+ match: /^(ask_user_question|ask_user|user_question)$/, label: 'Ask question',
667
+ },
668
+ { match: /^skill$/, label: 'Load skill' },
669
+ ];
670
+ /**
671
+ * The phrase for one tool, or the tool's own name when it belongs to no
672
+ * category. An unknown tool still says what it is, which is honest; inventing a
673
+ * category for it would not be.
674
+ */
675
+ function processLabel(name) {
676
+ for (const category of PROCESS_LABELS) {
677
+ if (category.match.test(name))
678
+ return category.label;
679
+ }
680
+ return name;
681
+ }
682
+ /** A group has to stand for repetition; one row is not repetition. */
683
+ const MIN_GROUP_BLOCKS = 2;
684
+ function blockIdentity(block, index) {
685
+ if (block.kind === 'tool')
686
+ return `tool:${block.callId}`;
687
+ if (block.kind === 'assistant')
688
+ return `assistant:${reasoningKey(block)}`;
689
+ // A run is keyed by its first block, and a run may begin with a process
690
+ // notice. Keying that by kind alone gave every such run in the document the
691
+ // same key, so opening one opened all of them and the reader's opened-run
692
+ // state bled across turns. The notice's durable source tells them apart; the
693
+ // index is the last resort for one that carries none, which still cannot
694
+ // collide with another notice's keyed run.
695
+ if (block.kind === 'notice' && block.process?.source !== undefined) {
696
+ return `notice:${block.process.source}`;
697
+ }
698
+ return `${block.kind}@${index}`;
699
+ }
700
+ /**
701
+ * Does this block belong to a turn's run, or stand on its own?
702
+ *
703
+ * Everything a turn does between the prompt and its answer is the run: every
704
+ * thought, every call, every reply the model wrote on the way, and the notices
705
+ * that report on that work — a retry, a background job settling. A thought used
706
+ * to close the run before it, which split a fifty-step turn into fifty runs;
707
+ * then a reply written mid-turn did the same, and a job notice split it again.
708
+ * The reference client folds the whole turn behind one line and leaves only the
709
+ * answer outside, and that is the shape a reader scrolling back wants: one row
710
+ * per turn, then what it concluded.
711
+ *
712
+ * An interrupted step is not work: it carries the notice that it stopped.
713
+ */
714
+ function isProcessBlock(block) {
715
+ if (block.kind === 'tool')
716
+ return true;
717
+ if (block.kind === 'notice')
718
+ return block.process !== undefined;
719
+ return block.kind === 'assistant' && block.interrupted !== true;
720
+ }
721
+ function blockTurn(block) {
722
+ if (block.kind === 'tool' || block.kind === 'assistant')
723
+ return block.turn;
724
+ if (block.kind === 'notice')
725
+ return block.process?.turn;
726
+ return undefined;
727
+ }
728
+ function isLiveBlock(block) {
729
+ return (block.kind === 'tool' && block.status === 'running')
730
+ || (block.kind === 'assistant' && block.streaming && block.text === '');
731
+ }
732
+ /**
733
+ * Project the transcript's turns into runs without touching the block list.
734
+ *
735
+ * A stretch of process blocks from one turn is one run. If the stretch ends on
736
+ * a reply, that reply is the turn's answer and stays outside; any reply before
737
+ * it was written on the way and is part of the run. A prompt, the per-turn
738
+ * changed-file record, or any notice that speaks to the reader ends the stretch,
739
+ * and so does a block from another turn — two turns with no prompt between
740
+ * them, such as a goal continuing itself, still read as two runs.
741
+ *
742
+ * The block list is deliberately left alone: a group is a rendering decision, so
743
+ * search, focus, and the row offsets keep addressing real blocks, and a group
744
+ * that is still growing cannot invalidate anything a reader already saw.
745
+ */
746
+ export function processGroups(blocks) {
747
+ const groups = [];
748
+ let start = -1;
749
+ let segmentTurn;
750
+ const flush = (end) => {
751
+ if (start < 0)
752
+ return;
753
+ // The turn's answer is the stretch's last reply, and process work can
754
+ // settle after it: a background job finishes, a retry is reported, both
755
+ // reported as process blocks precisely so they join the run instead of
756
+ // splitting it. Those trailing blocks are the run's work but not its
757
+ // conclusion, so the run ends at the answer and they follow it as rows of
758
+ // their own. Reading only the stretch's last block mistook them for the
759
+ // answer's absence and folded the answer itself into the run, where a
760
+ // folded standard transcript hid the whole reply.
761
+ //
762
+ // The last reply wins, not the first one met going back: a turn's earlier
763
+ // replies were written on the way and belong to the run, so scanning back
764
+ // from the end and taking the first reply would fold the one reply the
765
+ // model wrote mid-turn and leave the real answer outside it.
766
+ let answer;
767
+ for (let at = start; at < end; at += 1) {
768
+ const block = blocks[at];
769
+ if (block?.kind === 'assistant' && block.text !== '')
770
+ answer = at;
771
+ else if (block !== undefined && !isProcessBlock(block))
772
+ break;
773
+ }
774
+ const memberEnd = answer ?? end;
775
+ const last = blocks[answer ?? end - 1];
776
+ const absorbs = answer !== undefined && last?.kind === 'assistant' && last.reasoning !== '' ? answer : undefined;
777
+ const size = memberEnd - start + (absorbs === undefined ? 0 : 1);
778
+ const answerIndex = answer;
779
+ if (size >= MIN_GROUP_BLOCKS || (size > 0 && segmentTurn !== undefined)) {
780
+ let calls = 0;
781
+ let failures = 0;
782
+ let live = false;
783
+ // The whole stretch counts, not just the part before the answer: a
784
+ // background job that settles behind the reply is still work this run
785
+ // did, and a failed call among them is still a failure. Stopping at the
786
+ // answer left the header saying fewer calls than the reader could see,
787
+ // and said nothing about a call that failed after it.
788
+ for (let at = start; at < end; at += 1) {
789
+ const block = blocks[at];
790
+ if (isLiveBlock(block))
791
+ live = true;
792
+ if (block.kind === 'notice' && block.level === 'error')
793
+ failures += 1;
794
+ if (block.kind !== 'tool')
795
+ continue;
796
+ calls += 1;
797
+ if (block.status === 'error')
798
+ failures += 1;
799
+ }
800
+ const turn = segmentTurn ?? (blocks[end]?.kind === 'workspace' ? blocks[end].turn : undefined);
801
+ groups.push({
802
+ start,
803
+ until: end,
804
+ ...(answerIndex === undefined ? {} : { answer: answerIndex }),
805
+ // The run took this step's thought as its last row, so its words are
806
+ // painted without it. A separate flag rather than a second index: the
807
+ // block is always the answer.
808
+ tookThought: absorbs !== undefined,
809
+ calls,
810
+ failures,
811
+ live,
812
+ ...(turn === undefined ? {} : { turn }),
813
+ key: blockIdentity(blocks[start], start),
814
+ });
815
+ }
816
+ start = -1;
817
+ segmentTurn = undefined;
818
+ };
819
+ blocks.forEach((block, index) => {
820
+ if (!isProcessBlock(block)) {
821
+ flush(index);
822
+ return;
823
+ }
824
+ const turn = blockTurn(block);
825
+ if (start >= 0 && turn !== undefined && segmentTurn !== undefined && turn !== segmentTurn)
826
+ flush(index);
827
+ if (start < 0)
828
+ start = index;
829
+ segmentTurn ??= turn;
830
+ });
831
+ flush(blocks.length);
832
+ return groups;
833
+ }
834
+ /**
835
+ * The runs as the reader sees them now: {@link processGroups}, with the run a
836
+ * running turn is still adding to marked live.
837
+ *
838
+ * That run is live even between two events, when nothing is in flight — the
839
+ * model is choosing what to do next — and while the answer streams below it,
840
+ * so it becomes a group once, when the turn ends, rather than folding and
841
+ * unfolding at every pause. The host aims Ctrl+O with the same projection the
842
+ * renderer paints, so the two cannot disagree about what is folded.
843
+ */
844
+ export function turnGroups(state) {
845
+ const groups = processGroups(state.blocks);
846
+ if (state.status === 'idle')
847
+ return groups;
848
+ const last = state.blocks.length;
849
+ return groups.map(group => {
850
+ // `until` is the stretch's true end, including work that settled after the
851
+ // answer; comparing `end` would call a run with trailing work settled
852
+ // before the transcript's tail, and it would fold while the turn ran.
853
+ const trailing = group.until === last
854
+ || (group.until === last - 1 && state.blocks[group.until]?.kind === 'assistant');
855
+ const current = (group.turn === state.turn || (state.turn === 0 && trailing))
856
+ && state.turnSpans?.[state.turn]?.end === undefined;
857
+ return (current || (group.turn === undefined && trailing)) && !group.live ? { ...group, live: true } : group;
858
+ });
859
+ }
269
860
  function renderTranscriptBody(state, options, theme, spinnerFrame) {
270
861
  const toolsExpanded = options.toolsExpanded === true;
271
862
  const expandedTools = [...(options.expandedTools ?? [])].sort().join('\0');
863
+ const expandedReasoning = [...(options.expandedReasoning ?? [])].sort().join('\0');
864
+ const openedGroups = [...(options.openedGroups ?? [])].sort().join('\0');
272
865
  const themeName = options.themeName ?? 'dark';
273
866
  const trueColor = options.trueColor === true;
274
867
  const animatedSpinnerFrame = state.blocks.some(block => block.kind === 'tool' && block.status === 'running')
@@ -285,36 +878,279 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
285
878
  && cached.trueColor === trueColor
286
879
  && cached.themeName === themeName
287
880
  && cached.spinnerFrame === animatedSpinnerFrame
881
+ && cached.mathMode === options.mathMode
882
+ && cached.mermaidMode === options.mermaidMode
288
883
  && cached.toolsExpanded === toolsExpanded
289
884
  && cached.expandedTools === expandedTools
290
- && cached.searchKey === searchKey) {
291
- return { lines: cached.lines, blockStarts: cached.blockStarts };
885
+ && cached.expandedReasoning === expandedReasoning
886
+ && cached.openedGroups === openedGroups
887
+ && cached.toolDetailsKey === options.toolDetailsKey
888
+ && cached.turnSpans === state.turnSpans
889
+ && cached.turnDetailsKey === options.turnDetailsKey
890
+ && cached.searchKey === searchKey
891
+ && cached.status === state.status
892
+ && cached.focusBlock === options.focusBlock) {
893
+ return {
894
+ lines: cached.lines,
895
+ blockStarts: cached.blockStarts,
896
+ blockDrawStarts: cached.blockDrawStarts,
897
+ foldShape: cached.foldShape,
898
+ foldMarks: cached.foldMarks,
899
+ liveFrom: cached.liveFrom,
900
+ };
292
901
  }
293
902
  const matches = new Set(search?.matches ?? []);
294
903
  const lines = [];
295
904
  const blockStarts = [];
905
+ // Where each block is drawn, apart from the offsets above: a search can aim a
906
+ // block's offset at the thought it also carries, but its drawn span does not
907
+ // move, and the boundary mapper needs the drawn one.
908
+ const blockDrawStarts = [];
296
909
  let previous;
910
+ // A group is a rendering decision, so a block keeps its own identity and row
911
+ // offset whether or not the group around it is open. A collapsed group
912
+ // therefore owns the rows of its first block and nothing after it, which is
913
+ // why anything a reader can address — a search match, the focused block — has
914
+ // to force its own group open.
915
+ const groups = turnGroups(state);
916
+ const groupOf = new Int32Array(state.blocks.length).fill(-1);
917
+ const openGroups = new Set();
918
+ const absorbed = new Set();
919
+ // Thoughts a search hit has to show in full, beyond what the reader opened.
920
+ const openedThoughts = new Set(options.expandedReasoning ?? []);
921
+ // Where each answer's thought is actually drawn, for its fold mark. The
922
+ // navigation offset below may point elsewhere; the mark must not follow it.
923
+ const drawnThoughts = new Map();
924
+ const lastOfTurn = new Map();
925
+ groups.forEach((group, index) => {
926
+ for (let at = group.start; at < group.until; at += 1)
927
+ groupOf[at] = index;
928
+ // The turn's answer is painted below the run, not inside it. Leaving it
929
+ // claimed here would route the reply through the run's body and drop it
930
+ // from a folded transcript, which is what a reader most needs to see.
931
+ if (group.answer !== undefined && group.answer !== group.start)
932
+ groupOf[group.answer] = -1;
933
+ if (group.tookThought && group.answer !== undefined)
934
+ absorbed.add(group.answer);
935
+ if (group.turn !== undefined)
936
+ lastOfTurn.set(group.turn, index);
937
+ const reasoningHit = group.tookThought && group.answer !== undefined
938
+ && search !== undefined && matches.has(group.answer)
939
+ && blockMatchesQuery(state.blocks[group.answer].reasoning, search.query);
940
+ // A hit inside the thought the run took needs that thought laid out in
941
+ // full. Opening only the run shows a one-line preview, so the matched text
942
+ // stays out of sight while the transcript reports the hit. This is a render
943
+ // decision, not the reader's own state, so it goes in a local set rather
944
+ // than into the options the host owns.
945
+ if (reasoningHit && group.answer !== undefined) {
946
+ openedThoughts.add(reasoningKey(state.blocks[group.answer]));
947
+ }
948
+ const asked = toolsExpanded
949
+ || options.openedGroups?.has(group.key) === true
950
+ || state.blocks.slice(group.start, group.until).some(block => block.kind === 'tool' && options.expandedTools?.has(block.callId))
951
+ || reasoningHit
952
+ || [...matches].some(index => index >= group.start && index < group.until)
953
+ || options.focusBlock !== undefined
954
+ && options.focusBlock >= group.start && options.focusBlock < group.until;
955
+ if (asked)
956
+ openGroups.add(index);
957
+ });
958
+ // Every surface that is open, keyed and placed, so the host can tell where a
959
+ // change of shape starts. Global switches reshape from the top.
960
+ const foldMarks = [];
961
+ if (toolsExpanded)
962
+ foldMarks.push({ key: 'all', row: 0 });
963
+ let liveFrom;
964
+ const context = {
965
+ blocks: state.blocks, theme, themeName, trueColor, width: options.width,
966
+ matches, options: { ...options, expandedReasoning: openedThoughts },
967
+ };
968
+ const compactProcessRow = (block) => {
969
+ if (block?.kind === 'notice')
970
+ return block.process !== undefined && block.framed !== true;
971
+ return block?.kind === 'tool' && !toolsExpanded && !options.expandedTools?.has(block.callId);
972
+ };
973
+ // Only a turn's last run is summarized by how long the turn took; an earlier
974
+ // run in the same turn did not take all of it.
975
+ const elapsedOf = (index) => {
976
+ const group = groups[index];
977
+ if (group.live || group.turn === undefined || lastOfTurn.get(group.turn) !== index)
978
+ return undefined;
979
+ const span = state.turnSpans?.[group.turn];
980
+ return span?.end === undefined ? undefined : formatElapsed(span.end - span.start);
981
+ };
297
982
  for (let index = 0; index < state.blocks.length; index += 1) {
298
983
  const block = state.blocks[index];
299
- if (lines.length > 0) {
984
+ const groupIndex = groupOf[index] ?? -1;
985
+ if (groupIndex >= 0) {
986
+ // A run is painted whole by its first block: the header, then its rows
987
+ // when open. A collapsed run still shows the rows that failed, because a
988
+ // header saying only "something failed" would hide the row that says
989
+ // what — and forcing the whole run open for one failed grep would undo
990
+ // the fold for the most common failure there is.
991
+ const group = groups[groupIndex];
992
+ if (lines.length > 0)
993
+ lines.push('');
994
+ const headerRow = lines.length;
995
+ const opened = openGroups.has(groupIndex);
996
+ const shape = group.live ? 'live' : opened ? 'open' : 'folded';
997
+ foldMarks.push({ key: `run:${group.key}:${shape}`, row: headerRow });
998
+ if (group.live)
999
+ liveFrom ??= headerRow;
1000
+ // A live run has no header: it becomes a group when its turn ends.
1001
+ if (!group.live && !opened) {
1002
+ lines.push(...processGroupHeader(group, theme, options.width, elapsedOf(groupIndex), options.turnDetailsKey));
1003
+ }
1004
+ const bodyRow = lines.length;
1005
+ let offsets = new Map();
1006
+ if (group.live || opened) {
1007
+ const body = processGroupBody(group, context);
1008
+ for (const line of body.lines)
1009
+ lines.push(line);
1010
+ offsets = body.offsets;
1011
+ }
1012
+ else {
1013
+ for (let at = group.start; at < (group.answer ?? group.until); at += 1) {
1014
+ const member = state.blocks[at];
1015
+ if (!(member.kind === 'tool' && member.status === 'error')
1016
+ && !(member.kind === 'notice' && member.level === 'error'))
1017
+ continue;
1018
+ offsets.set(at, lines.length - bodyRow);
1019
+ lines.push(...(member.kind === 'tool' ? toolRowLine(member, theme, options.width, spinnerFrame) : groupMemberLines(at, context)));
1020
+ }
1021
+ }
1022
+ // The turn's answer, painted as a row of its own between the run's work
1023
+ // and the work that settled behind it, in the order the events arrived.
1024
+ // Painting it last would read as though the run concluded after the
1025
+ // notice, and — worse — would give the answer and the notice each other's
1026
+ // row, so a reader aiming at one landed on the other.
1027
+ if (group.answer !== undefined) {
1028
+ if (lines.length > 0)
1029
+ lines.push('');
1030
+ // The run painted this step's thought above; keep its row. The thought's
1031
+ // row is where it is *drawn*, which is not the same as where a search
1032
+ // should put the reader: a query in the reply aims at the reply, a query
1033
+ // in the thought at the thought. The draw position is what a fold mark
1034
+ // has to report, so it is kept apart from the navigation offset below
1035
+ // rather than being overwritten by it.
1036
+ const thoughtHit = group.tookThought
1037
+ && search !== undefined && matches.has(group.answer)
1038
+ && !blockMatchesQuery(state.blocks[group.answer].text, search.query);
1039
+ const thoughtRow = offsets.get(group.answer);
1040
+ drawnThoughts.set(group.answer, thoughtRow);
1041
+ // The answer's own drawn start is its words, never the thought above
1042
+ // them — a search aims `offsets` at the thought so the reader lands on
1043
+ // the match, and the boundary mapper must not mistake that for the
1044
+ // answer starting earlier.
1045
+ blockDrawStarts[group.answer] = lines.length;
1046
+ offsets.set(group.answer, lines.length - bodyRow);
1047
+ const answer = state.blocks[group.answer];
1048
+ const rendered = cachedBlockLines(answer, options, theme, themeName, trueColor, toolsExpanded, absorbed.has(group.answer));
1049
+ for (const line of (search !== undefined && matches.has(group.answer)
1050
+ ? rendered.map(row => blockMatchesQuery(stripAnsi(row), search.query) ? theme.inverse(row) : row)
1051
+ : rendered))
1052
+ lines.push(line);
1053
+ if (thoughtHit && thoughtRow !== undefined)
1054
+ blockStarts[group.answer] = bodyRow + thoughtRow;
1055
+ if (thoughtHit && thoughtRow !== undefined)
1056
+ offsets.set(group.answer, thoughtRow);
1057
+ // One block, two rows: the run painted its thought above, this painted
1058
+ // its words below. A search hit can be in either, and the offset has to
1059
+ // lead to the one that matched — aiming at the reply left a long
1060
+ // thought's match above the viewport, found and not readable.
1061
+ }
1062
+ // Work that settled after the answer still belongs to the run, so it is
1063
+ // painted as the run's rows directly beneath the reply rather than as
1064
+ // loose paragraphs at the page's left margin.
1065
+ for (let at = group.answer === undefined ? group.until : group.answer + 1; at < group.until; at += 1) {
1066
+ // Appended one at a time: a long run's member can be a very large
1067
+ // array, and spreading it into a call overflows the argument limit.
1068
+ const trailing = state.blocks[at];
1069
+ const failed = (trailing?.kind === 'notice' && trailing.level === 'error')
1070
+ || (trailing?.kind === 'tool' && trailing.status === 'error');
1071
+ if (group.live || opened || failed) {
1072
+ if (lines.length > 0 && !(trailing?.kind === 'notice' && state.blocks[at - 1]?.kind === 'notice'))
1073
+ lines.push('');
1074
+ offsets.set(at, lines.length - bodyRow);
1075
+ const rows = !group.live && !opened && trailing?.kind === 'tool'
1076
+ ? toolRowLine(trailing, theme, options.width, spinnerFrame)
1077
+ : groupMemberLines(at, context);
1078
+ for (const line of rows)
1079
+ lines.push(line);
1080
+ }
1081
+ }
1082
+ // A member the body did not paint answers to the header, which is the row
1083
+ // a reader aiming at it would land on anyway. Offsets are assigned by
1084
+ // block index, never by the order the rows were painted: the two differ
1085
+ // once a run carries an answer with work behind it, and a shifted offset
1086
+ // aims a search hit or a Ctrl+O at the wrong row.
1087
+ for (let at = group.start; at < group.until; at += 1) {
1088
+ const offset = offsets.get(at);
1089
+ const row = offset === undefined ? headerRow : bodyRow + offset;
1090
+ blockStarts[at] = row;
1091
+ const member = state.blocks[at];
1092
+ // Not `offsets`: a search may have aimed that at the thought, and this
1093
+ // array is the blocks' own drawn positions.
1094
+ if (blockDrawStarts[at] === undefined) {
1095
+ const drawn = offsets.get(at);
1096
+ blockDrawStarts[at] = drawn === undefined ? headerRow : bodyRow + drawn;
1097
+ }
1098
+ if (member.kind === 'tool' && options.expandedTools?.has(member.callId) === true) {
1099
+ foldMarks.push({ key: `call:${member.callId}`, row });
1100
+ }
1101
+ // The effective set, not the host's: a search can open a thought the
1102
+ // reader never asked for, and the rows that adds still reshaped. The
1103
+ // key is the same one the host already uses for a thought it opened, so
1104
+ // the two causes share one identity and neither is encoded in it.
1105
+ if (member.kind === 'assistant' && openedThoughts.has(reasoningKey(member))) {
1106
+ const drawn = drawnThoughts.get(at);
1107
+ foldMarks.push({ key: `thought:${reasoningKey(member)}`, row: drawn === undefined ? row : bodyRow + drawn });
1108
+ }
1109
+ }
1110
+ // Everything this run owned is painted, so the walk resumes past it.
1111
+ index = group.until - 1;
1112
+ continue;
1113
+ }
1114
+ else if (lines.length > 0) {
300
1115
  const previousCommand = commandSurfaceName(previous);
301
1116
  const currentCommand = commandSurfaceName(block);
302
1117
  if (previousCommand !== undefined && currentCommand !== undefined && previousCommand !== currentCommand) {
303
1118
  lines.push('', renderCommandSeparator(theme, options.width), '');
304
1119
  }
305
- else {
1120
+ else if (!compactProcessRow(block) || !compactProcessRow(state.blocks[index - 1])) {
306
1121
  lines.push('');
307
1122
  }
308
1123
  }
309
- blockStarts.push(lines.length);
310
- const expanded = toolsExpanded || (block.kind === 'tool' && options.expandedTools?.has(block.callId) === true);
311
- const rendered = cachedBlockLines(block, options, theme, themeName, trueColor, spinnerFrame, expanded);
1124
+ blockStarts[index] = lines.length;
1125
+ if (block.kind === 'tool' && options.expandedTools?.has(block.callId) === true) {
1126
+ foldMarks.push({ key: `call:${block.callId}`, row: lines.length });
1127
+ }
1128
+ if (block.kind === 'assistant' && options.expandedReasoning?.has(reasoningKey(block)) === true) {
1129
+ foldMarks.push({ key: `thought:${reasoningKey(block)}`, row: lines.length });
1130
+ }
1131
+ const liveBlock = state.status !== 'idle' && isProcessBlock(block)
1132
+ && (block.kind !== 'assistant' || block.reasoning !== '')
1133
+ && (blockTurn(block) === undefined || blockTurn(block) === state.turn);
1134
+ if (liveBlock)
1135
+ liveFrom ??= lines.length;
1136
+ const expanded = (block.kind === 'workspace' && groups.some((group, at) => group.turn === block.turn && openGroups.has(at))) || matches.has(index) || toolsExpanded
1137
+ || (block.kind === 'tool' && options.expandedTools?.has(block.callId) === true);
1138
+ const rendered = cachedBlockLines(block, options, theme, themeName, trueColor, expanded, absorbed.has(index));
312
1139
  lines.push(...(search !== undefined && matches.has(index)
313
1140
  ? rendered.map(line => blockMatchesQuery(stripAnsi(line), search.query) ? theme.inverse(line) : line)
314
1141
  : rendered));
1142
+ blockDrawStarts[index] = blockStarts[index];
315
1143
  previous = block;
316
1144
  }
1145
+ // Streaming text and a call settling change rows in place without changing
1146
+ // this; opening, folding, or a run settling at turn end do.
1147
+ const foldShape = foldMarks.map(mark => mark.key).join('\u0001');
317
1148
  transcriptBodyCache.set(state.blocks, {
1149
+ mathMode: options.mathMode,
1150
+ mermaidMode: options.mermaidMode,
1151
+ toolDetailsKey: options.toolDetailsKey,
1152
+ turnDetailsKey: options.turnDetailsKey,
1153
+ blockDrawStarts,
318
1154
  width: options.width,
319
1155
  colors: options.colors,
320
1156
  trueColor,
@@ -322,11 +1158,19 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
322
1158
  spinnerFrame: animatedSpinnerFrame,
323
1159
  toolsExpanded,
324
1160
  expandedTools,
1161
+ expandedReasoning,
1162
+ openedGroups,
325
1163
  searchKey,
1164
+ turnSpans: state.turnSpans,
1165
+ status: state.status,
1166
+ focusBlock: options.focusBlock,
1167
+ foldShape,
1168
+ foldMarks,
1169
+ liveFrom,
326
1170
  lines,
327
1171
  blockStarts,
328
1172
  });
329
- return { lines, blockStarts };
1173
+ return { lines, blockStarts, blockDrawStarts, foldShape, foldMarks, liveFrom };
330
1174
  }
331
1175
  function commandSurfaceName(block) {
332
1176
  if (block?.kind === 'toolCatalog')
@@ -337,6 +1181,15 @@ function commandSurfaceName(block) {
337
1181
  }
338
1182
  /** Rows moved per Shift+Arrow (OMP ScrollView `fastScrollLines`). */
339
1183
  export const TRANSCRIPT_FAST_SCROLL = 5;
1184
+ const promptSummaryCache = new WeakMap();
1185
+ function promptSummary(block) {
1186
+ const cached = promptSummaryCache.get(block);
1187
+ if (cached !== undefined)
1188
+ return cached;
1189
+ const summary = block.text.replace(/\s+/gu, ' ').trim();
1190
+ promptSummaryCache.set(block, summary);
1191
+ return summary;
1192
+ }
340
1193
  function earlierLabel(count) {
341
1194
  return '… ↑ ' + count + ' earlier line' + (count === 1 ? '' : 's') + ' ⟨Pg↑⟩';
342
1195
  }
@@ -346,14 +1199,22 @@ function laterLabel(count) {
346
1199
  /**
347
1200
  * Window `body` into `budget` rows with ↑/↓ overflow markers.
348
1201
  * `start` is the first body row to keep; non-finite values pin to the tail.
1202
+ *
1203
+ * `documentRows` says which of `body`'s rows the returned lines are, and where
1204
+ * in the returned lines they start. A window that is nothing but an overflow
1205
+ * marker carries none: `null`, because the frame drew no body rather than
1206
+ * because the caller forgot to say.
349
1207
  */
350
1208
  export function windowTranscript(body, budget, start, theme) {
351
1209
  const len = body.length;
352
1210
  if (budget <= 0) {
353
- return { lines: [], start: 0, maxStart: 0, budget, hiddenAbove: len, hiddenBelow: 0 };
1211
+ return { lines: [], start: 0, maxStart: 0, budget, hiddenAbove: len, hiddenBelow: 0, documentRows: null };
354
1212
  }
355
1213
  if (len <= budget) {
356
- return { lines: [...body], start: 0, maxStart: 0, budget, hiddenAbove: 0, hiddenBelow: 0 };
1214
+ return {
1215
+ lines: [...body], start: 0, maxStart: 0, budget, hiddenAbove: 0, hiddenBelow: 0,
1216
+ documentRows: { documentStart: 0, documentEnd: len, frameStart: 0 },
1217
+ };
357
1218
  }
358
1219
  const maxStart = Math.max(0, len - (budget - 1));
359
1220
  const s = Number.isFinite(start) ? Math.max(0, Math.min(Math.trunc(start), maxStart)) : maxStart;
@@ -372,6 +1233,7 @@ export function windowTranscript(body, budget, start, theme) {
372
1233
  budget,
373
1234
  hiddenAbove: atTail ? len : s,
374
1235
  hiddenBelow: atTail ? 0 : len - s,
1236
+ documentRows: null,
375
1237
  };
376
1238
  }
377
1239
  if (atTail) {
@@ -379,6 +1241,7 @@ export function windowTranscript(body, budget, start, theme) {
379
1241
  const hiddenAbove = len - take;
380
1242
  return {
381
1243
  lines: [theme.fg('dim', earlierLabel(hiddenAbove)), ...body.slice(hiddenAbove)],
1244
+ documentRows: { documentStart: hiddenAbove, documentEnd: len, frameStart: 1 },
382
1245
  start: hiddenAbove,
383
1246
  maxStart,
384
1247
  budget,
@@ -391,6 +1254,7 @@ export function windowTranscript(body, budget, start, theme) {
391
1254
  const hiddenBelow = len - take;
392
1255
  return {
393
1256
  lines: [...body.slice(0, take), theme.fg('dim', laterLabel(hiddenBelow))],
1257
+ documentRows: { documentStart: 0, documentEnd: take, frameStart: 0 },
394
1258
  start: 0,
395
1259
  maxStart,
396
1260
  budget,
@@ -401,6 +1265,7 @@ export function windowTranscript(body, budget, start, theme) {
401
1265
  if (budget === 2) {
402
1266
  return {
403
1267
  lines: [theme.fg('dim', earlierLabel(s)), body[s] ?? ''],
1268
+ documentRows: { documentStart: s, documentEnd: s + 1, frameStart: 1 },
404
1269
  start: s,
405
1270
  maxStart,
406
1271
  budget,
@@ -416,6 +1281,7 @@ export function windowTranscript(body, budget, start, theme) {
416
1281
  ...body.slice(s, s + take),
417
1282
  theme.fg('dim', laterLabel(hiddenBelow)),
418
1283
  ],
1284
+ documentRows: { documentStart: s, documentEnd: s + take, frameStart: 1 },
419
1285
  start: s,
420
1286
  maxStart,
421
1287
  budget,
@@ -640,6 +1506,17 @@ export function renderTurnError(text, theme, width) {
640
1506
  + (suffix === '' ? '' : theme.fg('dim', suffix));
641
1507
  return [padToWidth(line, width)];
642
1508
  }
1509
+ /** Human guidance remains visible until the Harness claims it for a model step. */
1510
+ export function renderSteering(inbox, theme, width) {
1511
+ const messages = inbox.filter(message => message.source.kind === 'user');
1512
+ if (messages.length === 0 || width <= 0)
1513
+ return [];
1514
+ const latest = queuedMessageLabel(messages.at(-1));
1515
+ const count = messages.length > 1 ? ` · ${messages.length}` : '';
1516
+ return [truncateToWidth(' ' + theme.fg('border', '│') + ' '
1517
+ + theme.bold(theme.fg('accent', 'Guidance · next step' + count))
1518
+ + theme.fg('dim', ' · ') + theme.fg('text', latest), width)];
1519
+ }
643
1520
  /** Compact, unframed pending-message view placed immediately above the composer. */
644
1521
  export function renderQueuedSubmissions(submissions, theme, width, inbox = []) {
645
1522
  const labels = [
@@ -664,9 +1541,9 @@ export function renderQueuedSubmissions(submissions, theme, width, inbox = []) {
664
1541
  return paintedLeft + ' ' + action;
665
1542
  };
666
1543
  if (labels.length === 1) {
667
- return [alignAction(rail + queueLabel + theme.fg('dim', ' · ') + theme.fg('text', labels[0] ?? ''), theme.fg('dim', '↑ edit'))];
1544
+ return [alignAction(rail + queueLabel + theme.fg('dim', ' · ') + theme.fg('dim', 'next turn · ') + theme.fg('text', labels[0] ?? ''), theme.fg('dim', '↑ edit'))];
668
1545
  }
669
- const summary = ` · ${labels.length}${hidden === 0 ? '' : ` · ${hidden} earlier`}`;
1546
+ const summary = ` · ${labels.length} · next turn${hidden === 0 ? '' : ` · ${hidden} earlier`}`;
670
1547
  const visibleLabels = labels.slice(start);
671
1548
  const lines = [
672
1549
  alignAction(rail + queueLabel + theme.fg('dim', summary), theme.fg('dim', '↑ edit latest'), false),
@@ -697,6 +1574,8 @@ export function renderView(state, options) {
697
1574
  lines: fitFrame(hub.lines, width),
698
1575
  cursor: hub.cursor,
699
1576
  cursorVisible: hub.cursorVisible,
1577
+ // A full-screen surface drew no transcript body.
1578
+ documentRows: null,
700
1579
  };
701
1580
  }
702
1581
  if (options.trajectory !== undefined) {
@@ -705,6 +1584,8 @@ export function renderView(state, options) {
705
1584
  lines: fitFrame(trajectory.lines, width),
706
1585
  cursor: trajectory.cursor,
707
1586
  cursorVisible: trajectory.cursorVisible,
1587
+ // A full-screen surface drew no transcript body.
1588
+ documentRows: null,
708
1589
  };
709
1590
  }
710
1591
  if (options.settings !== undefined && options.promptSelector === undefined) {
@@ -713,22 +1594,30 @@ export function renderView(state, options) {
713
1594
  lines: fitFrame(settings.lines, width),
714
1595
  cursor: settings.cursor,
715
1596
  cursorVisible: false,
1597
+ // A full-screen surface drew no transcript body.
1598
+ documentRows: null,
716
1599
  };
717
1600
  }
718
- if (options.promptSelector?.request.presentation === 'fullscreen-list') {
719
- const selector = renderPromptSelectorPage(options.promptSelector, theme, width, height, options.input, options.inputCursor, appName);
1601
+ if (options.promptSelector?.request.presentation === 'fullscreen-list' || options.promptSelector?.request.presentation === 'fullscreen-tree') {
1602
+ const selector = (options.promptSelector.request.presentation === 'fullscreen-tree' ? renderPromptTreePage : renderPromptSelectorPage)(options.promptSelector, theme, width, height, options.input, options.inputCursor, appName);
720
1603
  return {
721
1604
  lines: fitFrame(selector.lines, width),
1605
+ ...(selector.document === undefined ? {} : { promptDocument: selector.document }),
722
1606
  cursor: selector.cursor,
723
1607
  cursorVisible: true,
1608
+ // A full-screen surface drew no transcript body.
1609
+ documentRows: null,
724
1610
  };
725
1611
  }
726
- if (options.promptSelector?.request.presentation === 'plan-review') {
727
- const review = renderPlanReviewPage(options.promptSelector, theme, width, height, options.input, options.inputCursor, appName);
1612
+ if (options.promptSelector?.request.presentation === 'plan-review' || options.promptSelector?.request.presentation === 'document') {
1613
+ const review = options.promptSelector.request.documentImage === undefined ? renderPlanReviewPage(options.promptSelector, theme, width, height, options.input, options.inputCursor, appName) : renderImagePreviewPage(options.promptSelector, theme, width, height, appName, options.imageProtocol, options.imageCellSize);
728
1614
  return {
1615
+ ...(review.image === undefined ? {} : { image: review.image }),
729
1616
  lines: fitFrame(review.lines, width),
730
1617
  cursor: review.cursor,
731
1618
  cursorVisible: review.cursorVisible === true,
1619
+ // A full-screen surface drew no transcript body.
1620
+ documentRows: null,
732
1621
  ...(review.document === undefined ? {} : { promptDocument: review.document }),
733
1622
  };
734
1623
  }
@@ -764,6 +1653,8 @@ export function renderView(state, options) {
764
1653
  }, options.transcriptSearch.matches.length)
765
1654
  : options.inspected === undefined
766
1655
  ? slashInlineHint(options.input, options.inputCursor, options.commands)
1656
+ ?? (state.status === 'running' && options.steerKey !== undefined
1657
+ ? `Enter: next turn · ${options.steerKey}: next step` : null)
767
1658
  : options.inspected.writable
768
1659
  ? slashInlineHint(options.input, options.inputCursor, options.commands) ?? 'Enter to steer · Esc to return'
769
1660
  : 'Read-only · Esc to return';
@@ -813,7 +1704,10 @@ export function renderView(state, options) {
813
1704
  : undefined;
814
1705
  const queuedSubmissions = editor === undefined || options.inspected !== undefined
815
1706
  ? []
816
- : renderQueuedSubmissions(options.queuedSubmissions ?? [], theme, width, state.nextTurnInbox);
1707
+ : [
1708
+ ...renderSteering(state.nextStepInbox, theme, width),
1709
+ ...renderQueuedSubmissions(options.queuedSubmissions ?? [], theme, width, state.nextTurnInbox),
1710
+ ];
817
1711
  // Above the composer and below the queue: the failure explains why the queue
818
1712
  // is not moving, and the row survives until the next submission.
819
1713
  const turnError = editor === undefined || options.inspected !== undefined || state.turnError === undefined
@@ -823,6 +1717,10 @@ export function renderView(state, options) {
823
1717
  const goal = editor === undefined || options.inspected !== undefined || options.sessionControls?.goal === undefined
824
1718
  ? []
825
1719
  : renderGoalBar(options.sessionControls.goal, theme, width);
1720
+ const pendingCount = options.sessionControls?.pendingQuestions ?? 0;
1721
+ const questions = editor === undefined || options.inspected !== undefined || pendingCount === 0
1722
+ ? []
1723
+ : [truncateToWidth(theme.fg('warning', ` ${pendingCount} pending ${pendingCount === 1 ? 'question' : 'questions'} · /questions to answer`), width)];
826
1724
  const inspect = editor === undefined ? [] : renderInspectBanner(options.inspected, theme, width, spinnerFrame);
827
1725
  const subagents = editor === undefined
828
1726
  ? []
@@ -834,29 +1732,46 @@ export function renderView(state, options) {
834
1732
  const inputLines = promptSelector?.lines ?? settings?.lines ?? copySelector?.lines ?? search?.lines
835
1733
  ?? (editor === undefined ? [] : editor.lines);
836
1734
  const spacer = 1;
837
- const reserved = inputLines.length + working.length + inspect.length + subagents.length + todos.length + goal.length + queuedSubmissions.length + turnError.length + spacer + autocomplete.length + statusFooter.length;
1735
+ const reserved = inputLines.length + working.length + inspect.length + subagents.length + todos.length + goal.length + questions.length + queuedSubmissions.length + turnError.length + spacer + autocomplete.length + statusFooter.length;
838
1736
  const budget = Math.max(0, height - reserved);
839
- const focusStart = options.focusBlock === undefined
1737
+ const focusIndex = options.focusBlock === undefined
840
1738
  ? undefined
841
- : transcript.blockStarts[Math.max(0, Math.min(options.focusBlock, transcript.blockStarts.length - 1))];
1739
+ // Clamp against the blocks, not the offsets: a collapsed run's members
1740
+ // share its header's row, so the offset list can be shorter than the block
1741
+ // list, and clamping to it would aim the reader at a different block.
1742
+ : Math.max(0, Math.min(options.focusBlock, state.blocks.length - 1));
1743
+ const focusStart = focusIndex === undefined ? undefined : options.focusBlockEdge === 'end'
1744
+ ? Math.max(0, (transcript.blockStarts[focusIndex + 1] ?? transcript.lines.length) - Math.max(1, budget - 2))
1745
+ : transcript.blockStarts[focusIndex];
842
1746
  const requestedStart = focusStart === undefined
843
1747
  ? (options.scrollStart ?? Number.POSITIVE_INFINITY)
844
1748
  : transcriptStart + focusStart;
845
1749
  const hasOverlay = promptSelector !== undefined || settings !== undefined || copySelector !== undefined || search !== undefined;
846
1750
  const isFollowing = requestedStart === Number.POSITIVE_INFINITY && !hasOverlay;
847
- const livePinned = state.blocks.some(block => block.kind === 'tool' && block.status === 'running');
1751
+ // A run still at work reshapes as it goes — its newest rows scroll, it folds
1752
+ // when the turn ends — so none of it may freeze into history until it
1753
+ // settles, the same way a running call's rows may not.
1754
+ const livePinned = transcript.liveFrom !== undefined
1755
+ || state.blocks.some(block => block.kind === 'tool' && block.status === 'running');
848
1756
  const windowed = isFollowing
849
1757
  ? undefined
850
1758
  : windowTranscript(body, budget, requestedStart, theme);
851
1759
  const visible = windowed?.lines ?? body;
852
1760
  const lines = [...visible];
1761
+ const showJump = !hasOverlay && windowed !== undefined && budget >= 3
1762
+ && (windowed.hiddenBelow > 0 || (options.openedGroups?.size ?? 0) > 0 || options.toolsExpanded === true);
1763
+ const jumpText = truncateToWidth(width >= 38 ? ' ↓ Jump to latest message · End ' : ' ↓ Latest · End ', width);
1764
+ const jumpColumn = Math.max(0, Math.floor((width - visibleWidth(jumpText)) / 2));
1765
+ const jumpToLatest = showJump ? { row: lines.length, column: jumpColumn, width: visibleWidth(jumpText) } : undefined;
853
1766
  if (visible.length > 0)
854
- lines.push('');
855
- const bottomRows = working.length + goal.length + inspect.length + subagents.length + todos.length + queuedSubmissions.length + turnError.length + inputLines.length + autocomplete.length + statusFooter.length;
1767
+ lines.push(showJump
1768
+ ? ' '.repeat(jumpColumn) + theme.inverse(jumpText) : '');
1769
+ const bottomRows = working.length + goal.length + questions.length + inspect.length + subagents.length + todos.length + queuedSubmissions.length + turnError.length + inputLines.length + autocomplete.length + statusFooter.length;
856
1770
  const fill = Math.max(0, height - lines.length - bottomRows);
857
1771
  lines.push(...Array.from({ length: fill }, () => ''));
858
1772
  lines.push(...working);
859
1773
  lines.push(...goal);
1774
+ lines.push(...questions);
860
1775
  lines.push(...inspect);
861
1776
  lines.push(...subagents);
862
1777
  lines.push(...todos);
@@ -869,8 +1784,12 @@ export function renderView(state, options) {
869
1784
  let liveStart;
870
1785
  if (isFollowing) {
871
1786
  const firstPending = state.blocks.findIndex(isBlockPending);
872
- if (firstPending >= 0) {
873
- liveStart = transcriptStart + transcript.blockStarts[firstPending];
1787
+ const pendingRow = firstPending >= 0 ? transcript.blockStarts[firstPending] : undefined;
1788
+ const liveRow = transcript.liveFrom === undefined
1789
+ ? pendingRow
1790
+ : Math.min(pendingRow ?? transcript.liveFrom, transcript.liveFrom);
1791
+ if (liveRow !== undefined) {
1792
+ liveStart = transcriptStart + liveRow;
874
1793
  }
875
1794
  else {
876
1795
  liveStart = lines.length - bottomRows;
@@ -888,8 +1807,40 @@ export function renderView(state, options) {
888
1807
  // of measuring the complete native-scrollback history on every frame.
889
1808
  const trimmed = fitFrame(lines, width, isFollowing ? liveStart : 0);
890
1809
  const caret = promptSelector?.cursor ?? settings?.cursor ?? copySelector?.cursor ?? search?.cursor ?? editor?.cursor ?? { row: 0, column: 0 };
1810
+ // Which document rows this frame's body came from. `body` already includes the
1811
+ // header, so a window's own range is already in document coordinates and must
1812
+ // not have the header added again. No window means the whole body was drawn —
1813
+ // that is a known range, not a frame that failed to report one, and treating
1814
+ // it as "no body" would clear the record on every ordinary follow frame.
1815
+ const windowRange = windowed === undefined
1816
+ ? (body.length === 0 ? null : { documentStart: 0, documentEnd: body.length, frameStart: 0 })
1817
+ : windowed.documentRows;
1818
+ const bodySource = windowRange === undefined
1819
+ ? null
1820
+ : windowRange;
1821
+ const stickyHeaders = [];
1822
+ if (!hasOverlay && budget >= 3) {
1823
+ for (let at = 0; at < state.blocks.length; at += 1) {
1824
+ const block = state.blocks[at];
1825
+ if (block.kind !== 'user')
1826
+ continue;
1827
+ const previous = stickyHeaders.at(-1);
1828
+ if (previous !== undefined)
1829
+ previous.end = transcriptStart + transcript.blockStarts[at];
1830
+ const padding = width > 4 ? 2 : 0;
1831
+ const label = truncateToWidth(promptSummary(block), Math.max(1, width - padding * 2));
1832
+ stickyHeaders.push({
1833
+ start: transcriptStart + (transcript.blockStarts[at + 1] ?? transcript.lines.length),
1834
+ end: body.length,
1835
+ text: paintSurface(theme, 'userMessageBg', 'userMessageText', padToWidth(' '.repeat(padding) + label, width)),
1836
+ });
1837
+ }
1838
+ }
891
1839
  return {
892
1840
  lines: trimmed,
1841
+ stickyHeaders,
1842
+ ...(jumpToLatest === undefined ? {} : { jumpToLatest }),
1843
+ documentRows: bodySource,
893
1844
  cursor: {
894
1845
  row: editorStart + caret.row,
895
1846
  column: Math.min(caret.column, width),
@@ -904,12 +1855,19 @@ export function renderView(state, options) {
904
1855
  // history; the renderer needs to tell them apart because only the first may
905
1856
  // borrow the alternate buffer.
906
1857
  ...(liveStart === 0 ? { transientSurface: hasOverlay ? 'overlay' : 'scroll' } : {}),
907
- transcript: windowed ?? {
908
- start: 0,
909
- maxStart: 0,
910
- budget,
911
- hiddenAbove: 0,
912
- hiddenBelow: 0,
1858
+ transcript: {
1859
+ ...(windowed ?? {
1860
+ start: (body.length > budget ? body.length - Math.max(1, budget - 1) : 0),
1861
+ maxStart: (body.length > budget ? body.length - Math.max(1, budget - 1) : 0),
1862
+ budget,
1863
+ hiddenAbove: (body.length > budget ? body.length - Math.max(1, budget - 1) : 0),
1864
+ hiddenBelow: 0,
1865
+ }),
1866
+ blockStarts: transcript.blockStarts,
1867
+ blockDrawStarts: transcript.blockDrawStarts,
1868
+ foldShape: transcript.foldShape,
1869
+ foldMarks: transcript.foldMarks,
1870
+ bodyRow: transcriptStart,
913
1871
  },
914
1872
  };
915
1873
  }