@agi-fans/dsh-tui 0.21.0 → 0.22.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 (243) hide show
  1. package/lib/CHANGELOG.md +158 -1
  2. package/lib/chrome/box.d.ts +47 -16
  3. package/lib/chrome/box.js +172 -130
  4. package/lib/chrome/box.js.map +1 -1
  5. package/lib/chrome/code-highlight.d.ts +15 -0
  6. package/lib/chrome/code-highlight.js +60 -4
  7. package/lib/chrome/code-highlight.js.map +1 -1
  8. package/lib/chrome/command-output.d.ts +29 -2
  9. package/lib/chrome/command-output.js +100 -4
  10. package/lib/chrome/command-output.js.map +1 -1
  11. package/lib/chrome/main-screen-renderer.d.ts +16 -0
  12. package/lib/chrome/main-screen-renderer.js +53 -5
  13. package/lib/chrome/main-screen-renderer.js.map +1 -1
  14. package/lib/chrome/markdown.js +30 -10
  15. package/lib/chrome/markdown.js.map +1 -1
  16. package/lib/chrome/relative-time.d.ts +17 -0
  17. package/lib/chrome/relative-time.js +36 -0
  18. package/lib/chrome/relative-time.js.map +1 -1
  19. package/lib/chrome/secret-mask.d.ts +35 -0
  20. package/lib/chrome/secret-mask.js +180 -0
  21. package/lib/chrome/secret-mask.js.map +1 -0
  22. package/lib/chrome/status-config.d.ts +161 -51
  23. package/lib/chrome/status-config.js +237 -126
  24. package/lib/chrome/status-config.js.map +1 -1
  25. package/lib/chrome/status-line.d.ts +3 -2
  26. package/lib/chrome/status-line.js +105 -63
  27. package/lib/chrome/status-line.js.map +1 -1
  28. package/lib/chrome/terminal-colors.d.ts +85 -0
  29. package/lib/chrome/terminal-colors.js +230 -0
  30. package/lib/chrome/terminal-colors.js.map +1 -0
  31. package/lib/chrome/terminal-images.d.ts +9 -1
  32. package/lib/chrome/terminal-images.js +27 -4
  33. package/lib/chrome/terminal-images.js.map +1 -1
  34. package/lib/chrome/theme.d.ts +32 -9
  35. package/lib/chrome/theme.js +595 -498
  36. package/lib/chrome/theme.js.map +1 -1
  37. package/lib/chrome/tool-renderers.js +69 -13
  38. package/lib/chrome/tool-renderers.js.map +1 -1
  39. package/lib/chrome/tools-list.d.ts +1 -4
  40. package/lib/chrome/tools-list.js +1 -41
  41. package/lib/chrome/tools-list.js.map +1 -1
  42. package/lib/chrome/welcome-tips.d.ts +20 -8
  43. package/lib/chrome/welcome-tips.js +22 -42
  44. package/lib/chrome/welcome-tips.js.map +1 -1
  45. package/lib/commands/auth-ids.d.ts +25 -0
  46. package/lib/commands/auth-ids.js +27 -0
  47. package/lib/commands/auth-ids.js.map +1 -0
  48. package/lib/commands/auth-input.d.ts +34 -0
  49. package/lib/commands/auth-input.js +96 -0
  50. package/lib/commands/auth-input.js.map +1 -0
  51. package/lib/commands/auth-providers.d.ts +39 -0
  52. package/lib/commands/auth-providers.js +125 -0
  53. package/lib/commands/auth-providers.js.map +1 -0
  54. package/lib/commands/auth.d.ts +12 -20
  55. package/lib/commands/auth.js +42 -229
  56. package/lib/commands/auth.js.map +1 -1
  57. package/lib/commands/btw.d.ts +13 -1
  58. package/lib/commands/btw.js +37 -104
  59. package/lib/commands/btw.js.map +1 -1
  60. package/lib/commands/diff.js +1 -96
  61. package/lib/commands/diff.js.map +1 -1
  62. package/lib/commands/export.d.ts +1 -1
  63. package/lib/commands/export.js +3 -27
  64. package/lib/commands/export.js.map +1 -1
  65. package/lib/commands/files.d.ts +17 -1
  66. package/lib/commands/files.js +67 -107
  67. package/lib/commands/files.js.map +1 -1
  68. package/lib/commands/jobs.d.ts +19 -4
  69. package/lib/commands/jobs.js +50 -7
  70. package/lib/commands/jobs.js.map +1 -1
  71. package/lib/commands/model.js +108 -76
  72. package/lib/commands/model.js.map +1 -1
  73. package/lib/commands/permission.js +15 -16
  74. package/lib/commands/permission.js.map +1 -1
  75. package/lib/commands/plugins.js +133 -16
  76. package/lib/commands/plugins.js.map +1 -1
  77. package/lib/commands/schedule.d.ts +10 -0
  78. package/lib/commands/schedule.js +45 -14
  79. package/lib/commands/schedule.js.map +1 -1
  80. package/lib/commands/session-configuration.js +2 -10
  81. package/lib/commands/session-configuration.js.map +1 -1
  82. package/lib/commands/session.js +14 -38
  83. package/lib/commands/session.js.map +1 -1
  84. package/lib/commands/terminal.d.ts +14 -8
  85. package/lib/commands/terminal.js +19 -178
  86. package/lib/commands/terminal.js.map +1 -1
  87. package/lib/commands/trajectory.js +4 -1
  88. package/lib/commands/trajectory.js.map +1 -1
  89. package/lib/definition.d.ts +122 -22
  90. package/lib/definition.js.map +1 -1
  91. package/lib/index.d.ts +1 -0
  92. package/lib/index.js +1 -0
  93. package/lib/index.js.map +1 -1
  94. package/lib/input/actions.d.ts +51 -0
  95. package/lib/input/actions.js +120 -0
  96. package/lib/input/actions.js.map +1 -0
  97. package/lib/input/editor.d.ts +0 -10
  98. package/lib/input/editor.js +11 -9
  99. package/lib/input/editor.js.map +1 -1
  100. package/lib/input/image-paste.d.ts +19 -1
  101. package/lib/input/image-paste.js +82 -1
  102. package/lib/input/image-paste.js.map +1 -1
  103. package/lib/input/keybindings-config.d.ts +12 -5
  104. package/lib/input/keybindings-config.js +30 -57
  105. package/lib/input/keybindings-config.js.map +1 -1
  106. package/lib/input/keys.d.ts +16 -0
  107. package/lib/input/keys.js +38 -3
  108. package/lib/input/keys.js.map +1 -1
  109. package/lib/input/wheel-scroll.d.ts +46 -0
  110. package/lib/input/wheel-scroll.js +69 -0
  111. package/lib/input/wheel-scroll.js.map +1 -0
  112. package/lib/prompt-intents.d.ts +182 -0
  113. package/lib/prompt-intents.js +127 -0
  114. package/lib/prompt-intents.js.map +1 -0
  115. package/lib/runtime/cancellable.d.ts +75 -0
  116. package/lib/runtime/cancellable.js +76 -0
  117. package/lib/runtime/cancellable.js.map +1 -0
  118. package/lib/runtime/composer-images.js +5 -4
  119. package/lib/runtime/composer-images.js.map +1 -1
  120. package/lib/runtime/composer-shell.d.ts +17 -2
  121. package/lib/runtime/composer-shell.js +109 -43
  122. package/lib/runtime/composer-shell.js.map +1 -1
  123. package/lib/runtime/file-recovery-review.d.ts +0 -1
  124. package/lib/runtime/file-recovery-review.js +29 -30
  125. package/lib/runtime/file-recovery-review.js.map +1 -1
  126. package/lib/runtime/file-recovery-store.js +5 -1
  127. package/lib/runtime/file-recovery-store.js.map +1 -1
  128. package/lib/runtime/file-review.js +1 -1
  129. package/lib/runtime/file-review.js.map +1 -1
  130. package/lib/runtime/hook-settings.d.ts +2 -2
  131. package/lib/runtime/plain-tui.d.ts +2 -0
  132. package/lib/runtime/plain-tui.js +7 -1
  133. package/lib/runtime/plain-tui.js.map +1 -1
  134. package/lib/runtime/plugin-settings.d.ts +1 -1
  135. package/lib/runtime/plugin-settings.js +10 -8
  136. package/lib/runtime/plugin-settings.js.map +1 -1
  137. package/lib/runtime/provider-local.d.ts +57 -15
  138. package/lib/runtime/provider-local.js +1112 -471
  139. package/lib/runtime/provider-local.js.map +1 -1
  140. package/lib/runtime/runner.js +5 -11
  141. package/lib/runtime/runner.js.map +1 -1
  142. package/lib/runtime/settings-preferences.js +1 -1
  143. package/lib/runtime/settings-preferences.js.map +1 -1
  144. package/lib/runtime/startup-notices.js +2 -0
  145. package/lib/runtime/startup-notices.js.map +1 -1
  146. package/lib/runtime/terminal-modes.d.ts +47 -0
  147. package/lib/runtime/terminal-modes.js +86 -0
  148. package/lib/runtime/terminal-modes.js.map +1 -0
  149. package/lib/runtime/terminal-notifications.d.ts +3 -5
  150. package/lib/runtime/terminal-notifications.js +4 -8
  151. package/lib/runtime/terminal-notifications.js.map +1 -1
  152. package/lib/runtime/terminal-probe.d.ts +36 -0
  153. package/lib/runtime/terminal-probe.js +37 -0
  154. package/lib/runtime/terminal-probe.js.map +1 -0
  155. package/lib/runtime/terminal-session.d.ts +57 -0
  156. package/lib/runtime/terminal-session.js +164 -0
  157. package/lib/runtime/terminal-session.js.map +1 -0
  158. package/lib/session/interaction-adapter.js +7 -8
  159. package/lib/session/interaction-adapter.js.map +1 -1
  160. package/lib/session/message-queue.js +1 -1
  161. package/lib/session/message-queue.js.map +1 -1
  162. package/lib/session/package-metadata.d.ts +8 -0
  163. package/lib/session/package-metadata.js +8 -0
  164. package/lib/session/package-metadata.js.map +1 -1
  165. package/lib/session/session-controller.d.ts +10 -11
  166. package/lib/session/session-controller.js +95 -13
  167. package/lib/session/session-controller.js.map +1 -1
  168. package/lib/session/session-tree.d.ts +2 -0
  169. package/lib/session/session-tree.js +2 -0
  170. package/lib/session/session-tree.js.map +1 -1
  171. package/lib/session/tui-settings.d.ts +40 -11
  172. package/lib/session/tui-settings.js +73 -34
  173. package/lib/session/tui-settings.js.map +1 -1
  174. package/lib/views/autocomplete.d.ts +18 -2
  175. package/lib/views/autocomplete.js +51 -38
  176. package/lib/views/autocomplete.js.map +1 -1
  177. package/lib/views/copy-targets.d.ts +12 -13
  178. package/lib/views/copy-targets.js +16 -46
  179. package/lib/views/copy-targets.js.map +1 -1
  180. package/lib/views/document-reader.js +18 -5
  181. package/lib/views/document-reader.js.map +1 -1
  182. package/lib/views/event-views.js +42 -6
  183. package/lib/views/event-views.js.map +1 -1
  184. package/lib/views/history-search.js +15 -21
  185. package/lib/views/history-search.js.map +1 -1
  186. package/lib/views/hotkeys.d.ts +7 -1
  187. package/lib/views/hotkeys.js +49 -25
  188. package/lib/views/hotkeys.js.map +1 -1
  189. package/lib/views/list-navigation.d.ts +62 -0
  190. package/lib/views/list-navigation.js +73 -0
  191. package/lib/views/list-navigation.js.map +1 -0
  192. package/lib/views/list-view.d.ts +99 -0
  193. package/lib/views/list-view.js +165 -0
  194. package/lib/views/list-view.js.map +1 -0
  195. package/lib/views/prompt-selector.d.ts +31 -3
  196. package/lib/views/prompt-selector.js +249 -125
  197. package/lib/views/prompt-selector.js.map +1 -1
  198. package/lib/views/session-files.d.ts +1 -2
  199. package/lib/views/session-files.js +1 -11
  200. package/lib/views/session-files.js.map +1 -1
  201. package/lib/views/settings-list.d.ts +10 -8
  202. package/lib/views/settings-list.js +116 -283
  203. package/lib/views/settings-list.js.map +1 -1
  204. package/lib/views/trajectory-timeline.d.ts +72 -0
  205. package/lib/views/trajectory-timeline.js +133 -0
  206. package/lib/views/trajectory-timeline.js.map +1 -0
  207. package/lib/views/trajectory.d.ts +15 -4
  208. package/lib/views/trajectory.js +557 -123
  209. package/lib/views/trajectory.js.map +1 -1
  210. package/lib/views/transcript-render.d.ts +95 -25
  211. package/lib/views/transcript-render.js +501 -167
  212. package/lib/views/transcript-render.js.map +1 -1
  213. package/lib/views/transcript-search.d.ts +39 -6
  214. package/lib/views/transcript-search.js +46 -11
  215. package/lib/views/transcript-search.js.map +1 -1
  216. package/lib/views/transcript-types.d.ts +63 -5
  217. package/lib/views/transcript-types.js +23 -11
  218. package/lib/views/transcript-types.js.map +1 -1
  219. package/package.json +67 -86
  220. package/lib/commands/advisor.d.ts +0 -15
  221. package/lib/commands/advisor.js +0 -200
  222. package/lib/commands/advisor.js.map +0 -1
  223. package/lib/commands/loop.d.ts +0 -52
  224. package/lib/commands/loop.js +0 -307
  225. package/lib/commands/loop.js.map +0 -1
  226. package/lib/commands/rate.d.ts +0 -33
  227. package/lib/commands/rate.js +0 -212
  228. package/lib/commands/rate.js.map +0 -1
  229. package/lib/commands/steer.d.ts +0 -5
  230. package/lib/commands/steer.js +0 -24
  231. package/lib/commands/steer.js.map +0 -1
  232. package/lib/commands/worktree.d.ts +0 -5
  233. package/lib/commands/worktree.js +0 -103
  234. package/lib/commands/worktree.js.map +0 -1
  235. package/lib/runtime/terminal-reader.d.ts +0 -11
  236. package/lib/runtime/terminal-reader.js +0 -87
  237. package/lib/runtime/terminal-reader.js.map +0 -1
  238. package/lib/views/agent-hub.d.ts +0 -32
  239. package/lib/views/agent-hub.js +0 -209
  240. package/lib/views/agent-hub.js.map +0 -1
  241. package/lib/views/copy-selector.d.ts +0 -54
  242. package/lib/views/copy-selector.js +0 -170
  243. package/lib/views/copy-selector.js.map +0 -1
@@ -6,31 +6,42 @@
6
6
  */
7
7
  import { leadingSlashCommandNameRange, renderAutocomplete, slashInlineHint } from "./autocomplete.js";
8
8
  import { HISTORY_SEARCH_MAX_VISIBLE, renderHistorySearch } from "./history-search.js";
9
- import { renderEditor, renderFramedBlock, renderWelcome, renderWorking } from "../chrome/box.js";
9
+ import { DEEP_DRIVING_LABEL, renderEditor, renderFramedBlock, renderWelcome } from "../chrome/box.js";
10
10
  import { renderMarkdown } from "../chrome/markdown.js";
11
- import { renderCopySelector } from "./copy-selector.js";
12
11
  import { renderSettings } from "./settings-list.js";
13
12
  import { renderTrajectory } from "./trajectory.js";
14
- import { renderAgentHub } from "./agent-hub.js";
15
- import { renderImagePreviewPage, renderPlanReviewPage, renderPromptSelector, renderPromptSelectorPage, renderPromptTreePage, } from "./prompt-selector.js";
13
+ import { renderImagePreviewPage, renderPlanReviewPage, renderPromptSelector, renderPromptTreePage, } from "./prompt-selector.js";
16
14
  import { resolveStatusBarConfig } from "../chrome/status-config.js";
17
15
  import { renderPermissionBadge, renderStatusFooter } from "../chrome/status-line.js";
18
16
  import { createTheme, paintSurface, SPINNER, SYMBOL, BOX } from "../chrome/theme.js";
17
+ import { highlightToolText, toolPayloadLanguage } from "../chrome/code-highlight.js";
19
18
  import { renderGoalBar } from "../chrome/goal-bar.js";
20
- import { padToWidth, stripAnsi, truncateToWidth, visibleWidth, wrapText } from "../chrome/width.js";
19
+ import { padToWidth, sliceCells, stripAnsi, truncateToWidth, visibleWidth, wrapTextStable } from "../chrome/width.js";
21
20
  import { alignFileDiffs, wrapPaintedDiffRows, } from "../chrome/diff-render.js";
22
21
  import { workspaceBlockLines, WORKSPACE_COLLAPSED_LINES } from "./workspace-changes.js";
23
22
  import { blockFileAction } from "./file-actions.js";
24
23
  import { renderTool, toolArgSubject } from "../chrome/tool-renderers.js";
25
24
  import { toolArgsObject } from "../chrome/tool-args.js";
26
- import { renderToolsPanel } from "../chrome/tools-list.js";
27
- import { renderCommandOutput, renderCommandSeparator } from "../chrome/command-output.js";
25
+ import { renderCommandOutput, renderCommandSeparator, renderShell } from "../chrome/command-output.js";
26
+ import { terminalColorsKey } from "../chrome/terminal-colors.js";
28
27
  import { renderPathMentionRows } from "../chrome/path-mentions.js";
29
- import { blockMatchesQuery, transcriptSearchHint } from "./transcript-search.js";
28
+ import { formatElapsed } from "../chrome/relative-time.js";
29
+ import { maskSecrets } from "../chrome/secret-mask.js";
30
+ import { formatOverlayHint } from "./hotkey-format.js";
31
+ import { blockMatchesQuery, matchedRows, searchMatches, transcriptSearchHint, } from "./transcript-search.js";
32
+ export { formatElapsed };
30
33
  import { contentToText, prettyArgs, reasoningKey, } from "./transcript-types.js";
31
34
  function isBlockPending(block) {
32
35
  return (block.kind === 'assistant' && block.streaming) || (block.kind === 'tool' && block.status === 'running');
33
36
  }
37
+ /** The depth a frame encodes with. */
38
+ function colorDepthOf(options) {
39
+ return options.colorDepth ?? 'ansi16';
40
+ }
41
+ /** Cache key for everything the color depth and the reported terminal colors can change. */
42
+ function terminalKey(options) {
43
+ return `${colorDepthOf(options)}|${terminalColorsKey(options.terminal)}`;
44
+ }
34
45
  function exclusiveDiffs(presentation) {
35
46
  if (presentation?.result?.card === 'diff')
36
47
  return presentation.result.diffs;
@@ -108,7 +119,9 @@ function assistantMarkdown(source, theme, width, style) {
108
119
  */
109
120
  function userBubble(text, theme, width) {
110
121
  const gutter = ' ';
111
- const wrapped = renderPathMentionRows(text, Math.max(1, width - gutter.length), theme);
122
+ // Drawn masked, stored plain: the key still reaches the model and the copy
123
+ // action still returns what the reader sent, but a shared screen shows stars.
124
+ const wrapped = renderPathMentionRows(maskSecrets(text), Math.max(1, width - gutter.length), theme);
112
125
  const rows = ['', ...wrapped, ''];
113
126
  // The padding rows share the prompt's tint, so the prompt is one slab that
114
127
  // opens the turn. Unpainted, they were two blank rows of their own on top of
@@ -277,7 +290,7 @@ function toolRowSubject(block, presentation, label) {
277
290
  return firstLineOf(verb !== '' && title.startsWith(verb + ' ') ? title.slice(verb.length + 1) : title);
278
291
  }
279
292
  /** Tool content shared by a running turn and a completed turn opened for reading. */
280
- function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt+O') {
293
+ function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt+O', surface = 'toolSuccessBg') {
281
294
  const full = mode !== 'preview';
282
295
  const presentation = renderTool({
283
296
  name: block.name, arguments: prettyArgs(block.args), output: block.output,
@@ -303,8 +316,15 @@ function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt
303
316
  : call?.card === 'generic' || call?.card === 'diff' ? call.title
304
317
  : !read && presentation.title !== undefined && presentation.title !== block.name ? presentation.title
305
318
  : `${block.name}${subject === '' ? '' : ` ${subject}`}`;
306
- const head = theme.bold(theme.fg(block.status === 'error' ? 'error' : 'toolTitle', title));
307
- const rows = wrapText(head, room);
319
+ // The command line is one ink, not a highlighted program.
320
+ //
321
+ // A command is the reader's own intent, and it has to be recognisable at a
322
+ // glance; syntax colouring it competes with the output below, which is the
323
+ // part that actually has to be read. Pi splits the two the same way: a flat
324
+ // bold `toolTitle` line for the call, and language highlighting for whatever
325
+ // the call returned.
326
+ const titleInk = block.status === 'error' ? 'error' : 'toolTitle';
327
+ const rows = wrapTextStable(theme.bold(theme.fg(titleInk, title)), room);
308
328
  let body = [];
309
329
  if (mode === 'details') {
310
330
  let input = block.args;
@@ -313,20 +333,26 @@ function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt
313
333
  }
314
334
  catch { /* Partial arguments retain their received text. */ }
315
335
  const output = presentation.output.length > 0 ? presentation.output : block.output.split('\n');
316
- const outputRows = diffs === undefined
317
- ? output.flatMap(line => wrapText(theme.fg(block.status === 'error' ? 'error' : 'toolOutput', line), room))
318
- : wrapPaintedDiffRows(alignFileDiffs(diffs), theme, room);
336
+ const detailLanguage = shell ? 'bash' : toolPayloadLanguage(block.name, subject);
337
+ const outputRows = diffs !== undefined
338
+ ? wrapPaintedDiffRows(alignFileDiffs(diffs), theme, room)
339
+ : (detailLanguage === undefined
340
+ ? output.map(line => theme.fg(block.status === 'error' ? 'error' : 'toolOutput', line))
341
+ : highlightedLines(output, detailLanguage, theme, block.status === 'error' ? 'error' : undefined))
342
+ .flatMap(line => wrapTextStable(line, room));
319
343
  body = [
320
- ...wrapText(theme.fg('dim', 'Input'), room),
321
- ...input.split('\n').flatMap(line => wrapText(theme.fg('toolOutput', line), room)),
322
- '', ...wrapText(theme.fg('dim', 'Output'), room),
344
+ ...wrapTextStable(theme.fg('dim', 'Input'), room),
345
+ // The arguments are JSON here (they were re-serialized above), so they are
346
+ // highlighted as JSON whatever the tool itself reads.
347
+ ...highlightedLines(input.split('\n'), 'json', theme, undefined, room),
348
+ '', ...wrapTextStable(theme.fg('dim', 'Output'), room),
323
349
  ...outputRows,
324
350
  ];
325
351
  // Keep the tool's rich presentation, but expose received text it replaced.
326
352
  if (block.output !== '' && (diffs !== undefined || block.output !== output.join('\n'))) {
327
- body.push('', ...wrapText(theme.fg('dim', 'Raw result'), room));
353
+ body.push('', ...wrapTextStable(theme.fg('dim', 'Raw result'), room));
328
354
  for (const line of block.output.split('\n')) {
329
- body.push(...wrapText(theme.fg(block.status === 'error' ? 'error' : 'toolOutput', line), room));
355
+ body.push(...wrapTextStable(theme.fg(block.status === 'error' ? 'error' : 'toolOutput', line), room));
330
356
  }
331
357
  }
332
358
  }
@@ -334,13 +360,21 @@ function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt
334
360
  body = wrapPaintedDiffRows(alignFileDiffs(diffs), theme, room);
335
361
  }
336
362
  else if (full || !read || block.status === 'error') {
337
- const output = presentation.output.map(line => theme.fg(block.status === 'error' ? 'error' : 'toolOutput', line));
363
+ // One highlight pass over the whole payload. Painting line by line handed
364
+ // the tokenizer a fragment with no opening construct, so a second and third
365
+ // command row came back bare — the "only the first line is highlighted"
366
+ // report. A file payload uses the subject's own language.
367
+ const language = shell ? 'bash' : toolPayloadLanguage(block.name, subject);
368
+ const output = language === undefined
369
+ ? presentation.output.map(line => theme.fg(block.status === 'error' ? 'error' : 'toolOutput', line))
370
+ : highlightedLines(presentation.output, language, theme, block.status === 'error' ? 'error' : undefined);
338
371
  const input = !shell && !read && (call?.card === 'generic' || (call === undefined && presentation.title !== block.name))
339
- ? presentation.input.flatMap(line => wrapText(theme.fg('toolOutput', line), room)) : [];
340
- body = [...input, ...output.flatMap(line => wrapText(line, room))];
372
+ ? highlightedLines(presentation.input, language ?? 'text', theme, undefined, room)
373
+ : [];
374
+ body = [...input, ...output.flatMap(line => wrapTextStable(line, room))];
341
375
  // Writes carry their content in arguments before the result arrives.
342
376
  if (body.length === 0 && block.name === 'write' && typeof args?.['content'] === 'string') {
343
- body = args['content'].split('\n').flatMap(line => wrapText(theme.fg('toolOutput', line), room));
377
+ body = highlightedLines(args['content'].split('\n'), toolPayloadLanguage('write', subject) ?? 'text', theme, undefined, room);
344
378
  }
345
379
  }
346
380
  while (body.length > 0 && stripAnsi(body[body.length - 1]).trim() === '')
@@ -353,12 +387,12 @@ function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt
353
387
  const shortcut = detailsKey === 'Disabled' ? '' : ` · ${detailsKey}: Expand`;
354
388
  const hint = theme.fg('dim', `… ${omitted} ${shell ? 'earlier' : 'more'} lines${shortcut}`);
355
389
  if (shell)
356
- body.unshift(...wrapText(hint, room));
390
+ body.unshift(...wrapTextStable(hint, room));
357
391
  else
358
- body.push(...wrapText(hint, room));
392
+ body.push(...wrapTextStable(hint, room));
359
393
  }
360
394
  if (!full && read && block.status !== 'error' && block.output !== '' && detailsKey !== 'Disabled') {
361
- body.push(...wrapText(theme.fg('dim', `${detailsKey}: Expand result`), room));
395
+ body.push(...wrapTextStable(theme.fg('dim', `${detailsKey}: Expand result`), room));
362
396
  }
363
397
  if (full && block.status === 'error' && call?.card === 'diff')
364
398
  rows.push('', ...wrapPaintedDiffRows(alignFileDiffs(call.diffs), theme, room));
@@ -368,10 +402,15 @@ function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt
368
402
  rows.push(row);
369
403
  }
370
404
  if (block.status === 'error')
371
- rows.push(...wrapText(theme.fg('error', 'Tool failed'), room));
372
- if (block.job !== undefined)
373
- rows.push(...wrapText(theme.fg(block.status === 'error' ? 'error' : 'muted', `Job ${block.job.id} · ${block.job.detail ?? block.job.status}`), room));
374
- const bg = block.status === 'running' ? 'toolPendingBg' : block.status === 'error' ? 'toolErrorBg' : 'toolSuccessBg';
405
+ rows.push(...wrapTextStable(theme.fg('error', 'Tool failed'), room));
406
+ if (block.job !== undefined) {
407
+ // The settlement line belongs to the call, not to its output: without a gap
408
+ // it reads as one more line of whatever the command printed.
409
+ if (rows.length > 0)
410
+ rows.push('');
411
+ rows.push(...wrapTextStable(theme.fg(block.status === 'error' ? 'error' : 'muted', `Job ${block.job.id} · ${block.job.detail ?? block.job.status}`), room));
412
+ }
413
+ const bg = block.status === 'running' ? 'toolPendingBg' : block.status === 'error' ? 'toolErrorBg' : surface;
375
414
  return ['', ...rows, ''].map(row => paintSurface(theme, bg, 'toolOutput', padToWidth(' '.repeat(padding + nest) + row, width)));
376
415
  }
377
416
  function firstLineOf(text) {
@@ -382,6 +421,18 @@ function firstLineOf(text) {
382
421
  }
383
422
  return '';
384
423
  }
424
+ /**
425
+ * One row standing in for a thought the reader has not opened.
426
+ *
427
+ * The first line is kept so a collapsed thought still says what it was about —
428
+ * a labelled placeholder would make every thought look the same.
429
+ */
430
+ function reasoningSummaryRow(reasoning, theme, width) {
431
+ const paddingX = width > ASSISTANT_PADDING_X * 2 ? ASSISTANT_PADDING_X : 0;
432
+ const label = `Thinking: ${firstLineOf(reasoning)}`;
433
+ return assistantContentLines([theme.fg('dim', truncateToWidth(label, Math.max(1, width - paddingX * 2)))], width, paddingX)[0]
434
+ ?? '';
435
+ }
385
436
  /** Thinking uses the same quiet Markdown style throughout the turn. */
386
437
  function openReasoningLines(block, theme, width, mathMode = 'auto') {
387
438
  return assistantMarkdown(block.reasoning, theme, width, { color: 'thinkingText', italic: true, mathMode });
@@ -402,27 +453,103 @@ function groupMembers(group) {
402
453
  * last row and its words are the answer painted below the run, so the run
403
454
  * paints the thought and nothing else.
404
455
  */
405
- function groupMemberLines(index, context, thoughtOnly = false) {
456
+ function groupMemberLines(index, context, thoughtOnly = false, toolOrdinal = 0,
457
+ /** The reader opened this run, so its thoughts are laid out in full. */
458
+ openRun = false) {
406
459
  const { blocks, options, theme, width } = context;
407
460
  const block = blocks[index];
408
461
  // Search must reveal a match even when it falls outside a tool's normal preview.
409
462
  const full = block.kind === 'tool' && (context.matches.has(index) || options.expandedTools?.has(block.callId) === true);
410
- const signature = [width, options.colors, context.trueColor, context.themeName, options.mathMode, options.mermaidMode, thoughtOnly, full, options.toolDetailsKey].join('\0');
463
+ const surface = toolOrdinal % 2 === 0 ? 'toolSuccessBg' : 'toolAltBg';
464
+ const thinkingOpen = openRun
465
+ || (block.kind === 'assistant' && context.openedThoughts.has(reasoningKey(block)));
466
+ const signature = [width, options.colors, context.depth, context.themeName, terminalKey(options), options.mathMode, options.mermaidMode, thoughtOnly, full, options.toolDetailsKey, surface, thinkingOpen].join('\0');
411
467
  const cached = groupMemberCache.get(block);
412
468
  let lines;
413
469
  if (cached?.signature === signature)
414
470
  lines = cached.lines;
415
471
  else {
416
- lines = block.kind === 'tool' ? toolBlockLines(block, theme, width, full ? 'details' : 'preview', options.toolDetailsKey)
417
- : block.kind === 'assistant' ? thoughtOnly ? openReasoningLines(block, theme, width, options.mathMode)
418
- : blockLines(block, theme, width, { mathMode: options.mathMode ?? 'auto', mermaidMode: options.mermaidMode ?? 'auto' })
472
+ lines = block.kind === 'tool' ? toolBlockLines(block, theme, width, full ? 'details' : 'preview', options.toolDetailsKey, surface)
473
+ : block.kind === 'assistant' ? thoughtOnly
474
+ ? (options.hideThinking !== true || thinkingOpen
475
+ ? openReasoningLines(block, theme, width, options.mathMode)
476
+ : [reasoningSummaryRow(block.reasoning, theme, width)])
477
+ : blockLines(block, theme, width, {
478
+ mathMode: options.mathMode ?? 'auto',
479
+ mermaidMode: options.mermaidMode ?? 'auto',
480
+ hideThinking: options.hideThinking === true,
481
+ thinkingOpen,
482
+ })
419
483
  : block.kind === 'notice' ? blockLines(block, theme, width) : [];
420
484
  groupMemberCache.set(block, { signature, lines });
421
485
  }
422
- const search = options.transcriptSearch;
423
- return search !== undefined && context.matches.has(index)
424
- ? lines.map(line => blockMatchesQuery(stripAnsi(line), search.query) ? theme.inverse(line) : line)
425
- : [...lines];
486
+ return [...paintSearch(lines, index, context.occurrences, theme)];
487
+ }
488
+ /**
489
+ * Paint the query occurrences of one block onto its rows.
490
+ *
491
+ * One painter for both the run path and the block path: painting the row instead
492
+ * of the occurrence was the original defect, and having two copies of the fix
493
+ * would let one of them regress back to it.
494
+ */
495
+ function paintSearch(lines, block, occurrences, theme) {
496
+ const spansByRow = matchedRows(occurrences, block);
497
+ if (spansByRow.size === 0)
498
+ return lines;
499
+ return lines.map((line, row) => {
500
+ const spans = spansByRow.get(row);
501
+ return spans === undefined ? line : inverseSpans(line, spans, theme);
502
+ });
503
+ }
504
+ /**
505
+ * Invert the given display-cell spans of one rendered row.
506
+ *
507
+ * `sliceCells` is what makes this safe for CJK, emoji, and combining marks: the
508
+ * spans are in display cells, which is the only measure the reader's eye agrees
509
+ * with. Offsets computed from string indexes land mid-glyph and shear the row.
510
+ */
511
+ function inverseSpans(line, spans, theme) {
512
+ const plain = stripAnsi(line);
513
+ const total = visibleWidth(plain);
514
+ // Merge overlapping spans, then walk them left to right so no cell is painted
515
+ // twice and the untouched gaps keep their original styling.
516
+ const ordered = [...spans].sort((left, right) => left.start - right.start);
517
+ const merged = [];
518
+ for (const span of ordered) {
519
+ const last = merged.at(-1);
520
+ const end = span.start + span.width;
521
+ if (last !== undefined && span.start <= last.start + last.width) {
522
+ last.width = Math.max(last.width, end - last.start);
523
+ }
524
+ else
525
+ merged.push({ start: span.start, width: span.width });
526
+ }
527
+ let out = '';
528
+ let at = 0;
529
+ for (const span of merged) {
530
+ const start = Math.max(at, Math.min(span.start, total));
531
+ const end = Math.min(total, Math.max(start, span.start + span.width));
532
+ if (start > at)
533
+ out += sliceCells(line, at, start - at);
534
+ if (end > start)
535
+ out += theme.inverse(sliceCells(line, start, end - start));
536
+ at = end;
537
+ }
538
+ if (at < total)
539
+ out += sliceCells(line, at, total - at);
540
+ return out;
541
+ }
542
+ /**
543
+ * Highlight a payload once, then wrap it.
544
+ *
545
+ * Every caller goes through this so no tool view can quietly fall back to
546
+ * per-line painting — the shape that produced a single highlighted row above a
547
+ * block of plain ones.
548
+ */
549
+ function highlightedLines(lines, language, theme, errorInk, width) {
550
+ const highlighted = highlightToolText(lines.join('\n'), language, theme);
551
+ const rows = highlighted.map(line => errorInk === undefined ? line : theme.fg(errorInk, line));
552
+ return width === undefined ? rows : rows.flatMap(line => wrapTextStable(line, width));
426
553
  }
427
554
  /** A notice that reports on the run's work, as one of its rows. */
428
555
  function processNoticeLine(block, theme, width, indent) {
@@ -443,7 +570,10 @@ function processGroupBody(group, context) {
443
570
  const offsets = new Map();
444
571
  const spans = [];
445
572
  for (const index of groupMembers(group)) {
446
- const rows = groupMemberLines(index, context, index === group.answer && group.tookThought);
573
+ const isTool = context.blocks[index]?.kind === 'tool';
574
+ const rows = groupMemberLines(index, context, index === group.answer && group.tookThought, context.toolCards.count, true);
575
+ if (isTool && rows.length > 0)
576
+ context.toolCards.count += 1;
447
577
  if (rows.length > 0 && lines.length > 0)
448
578
  lines.push('');
449
579
  offsets.set(index, lines.length);
@@ -454,16 +584,6 @@ function processGroupBody(group, context) {
454
584
  }
455
585
  return { lines, offsets, spans };
456
586
  }
457
- /** A settled turn's length, the way a person would say it. */
458
- export function formatElapsed(ms) {
459
- const seconds = Math.max(0, Math.round(ms / 1000));
460
- if (seconds < 60)
461
- return `${seconds}s`;
462
- const minutes = Math.floor(seconds / 60);
463
- if (minutes < 60)
464
- return `${minutes}m ${seconds % 60}s`;
465
- return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
466
- }
467
587
  /** A completed turn's duration and any failures remain visible when folded. */
468
588
  function processGroupHeader(group, theme, width, elapsed, expandKey = 'Ctrl+O') {
469
589
  const label = elapsed === undefined ? 'Worked' : `Worked for ${elapsed}`;
@@ -480,13 +600,16 @@ function processGroupHeader(group, theme, width, elapsed, expandKey = 'Ctrl+O')
480
600
  * to direct callers; turn inspection uses previews. omitReasoning excludes
481
601
  * thinking already drawn by a run.
482
602
  */
483
- export function blockLines(block, theme, width, { toolsExpanded = false, omitReasoning = false, toolDetailsKey = 'Alt+O', mathMode = 'auto', mermaidMode = 'auto' } = {}) {
603
+ export function blockLines(block, theme, width, { toolsExpanded = false, omitReasoning = false, toolDetailsKey = 'Alt+O', mathMode = 'auto', mermaidMode = 'auto', hideThinking = false, thinkingOpen = false, expandedOutput = false, outputExpandKey = 'Ctrl+O' } = {}) {
484
604
  if (block.kind === 'user')
485
605
  return userBubble(block.text, theme, width);
486
606
  if (block.kind === 'assistant') {
487
607
  const lines = [];
488
608
  if (block.reasoning !== '' && !omitReasoning) {
489
- lines.push(...openReasoningLines(block, theme, width, mathMode));
609
+ const collapsed = hideThinking && !thinkingOpen;
610
+ lines.push(...(collapsed
611
+ ? [reasoningSummaryRow(block.reasoning, theme, width)]
612
+ : openReasoningLines(block, theme, width, mathMode)));
490
613
  if (block.text !== '')
491
614
  lines.push('');
492
615
  }
@@ -506,12 +629,30 @@ export function blockLines(block, theme, width, { toolsExpanded = false, omitRea
506
629
  if (block.kind === 'tool') {
507
630
  return toolBlockLines(block, theme, width, toolsExpanded ? 'full' : 'preview', toolDetailsKey);
508
631
  }
509
- if (block.kind === 'toolCatalog')
510
- return renderToolsPanel(block.tools, theme, width, toolsExpanded);
511
632
  if (block.kind === 'workspace')
512
633
  return workspaceBlockLines(block, theme, width, toolsExpanded);
513
- if (block.kind === 'commandOutput')
514
- return renderCommandOutput(block.command, block.text, theme, width);
634
+ if (block.kind === 'commandOutput') {
635
+ return renderCommandOutput(block.command, block.text, theme, width, {
636
+ ...(block.surface === undefined ? {} : { surface: block.surface }),
637
+ expanded: expandedOutput,
638
+ ...(expandedOutput ? {} : { expandKey: outputExpandKey }),
639
+ });
640
+ }
641
+ if (block.kind === 'aside') {
642
+ // One line when closed, the whole answer when opened — the same fold a long
643
+ // command result uses, so there is one expand gesture in the product.
644
+ const head = ' ' + theme.bold(theme.fg('accent', '/btw')) + ' ' + theme.fg('text', block.question);
645
+ if (expandedOutput) {
646
+ return [
647
+ truncateToWidth(head, width),
648
+ '',
649
+ ...renderMarkdown(block.text, theme, Math.max(1, width - 2)).map(line => ' ' + line),
650
+ ];
651
+ }
652
+ return [truncateToWidth(head + theme.fg('dim', ` · ${outputExpandKey}: Expand`), width)];
653
+ }
654
+ if (block.kind === 'shell')
655
+ return renderShell(block, theme, width);
515
656
  if (block.boundary === true)
516
657
  return [renderTranscriptBoundary(block.text, theme, width)];
517
658
  if (block.process !== undefined && block.framed !== true)
@@ -520,11 +661,11 @@ export function blockLines(block, theme, width, { toolsExpanded = false, omitRea
520
661
  const prefix = ' ';
521
662
  const continuation = ' '.repeat(visibleWidth(prefix));
522
663
  const tone = block.level === 'error' ? 'error' : block.level === 'warning' ? 'warning' : 'dim';
523
- return wrapText(block.text, Math.max(1, width - visibleWidth(prefix))).map((line, index) => truncateToWidth((index === 0 ? prefix : continuation) + theme.fg(tone, line), width));
664
+ return wrapTextStable(block.text, Math.max(1, width - visibleWidth(prefix))).map((line, index) => truncateToWidth((index === 0 ? prefix : continuation) + theme.fg(tone, line), width));
524
665
  }
525
666
  const state = block.level === 'error' ? 'error' : block.level === 'warning' ? 'warning' : 'info';
526
667
  const paint = (text) => theme.fg(block.level === 'error' ? 'error' : block.level === 'warning' ? 'warning' : 'dim', text);
527
- const wrapped = wrapText(block.text, Math.max(1, width - 4));
668
+ const wrapped = wrapTextStable(block.text, Math.max(1, width - 4));
528
669
  const title = paint(wrapped[0] ?? '');
529
670
  return renderFramedBlock({
530
671
  header: title,
@@ -568,6 +709,55 @@ function renderTranscriptBoundary(label, theme, width) {
568
709
  const left = Math.floor(room / 2);
569
710
  return rule.repeat(left) + ' ' + labelText + ' ' + rule.repeat(room - left);
570
711
  }
712
+ /**
713
+ * Rendered rows of every block, as the search index sees them.
714
+ *
715
+ * The searchable text used to be the block's source (`blockSearchText`), which is
716
+ * not what the reader is looking at: Markdown source carries `**` and backticks,
717
+ * a table is one source line but several rendered rows, and a folded run hides
718
+ * rows that the source still contains. Matching rendered rows is the only way the
719
+ * reported hit and the painted span agree.
720
+ *
721
+ * Plain text only — the caller strips SGR — and cached on the block array, which
722
+ * is replaced rather than mutated whenever the transcript changes.
723
+ */
724
+ const renderedBlockCache = new WeakMap();
725
+ export function renderedBlocks(state, options) {
726
+ const theme = createTheme(options.colors === true, options.colorDepth ?? 'truecolor', options.themeName ?? 'dark', options.terminal);
727
+ const key = [
728
+ options.width,
729
+ options.toolsExpanded === true,
730
+ options.hideThinking === true,
731
+ [...(options.expandedTools ?? [])].sort().join(','),
732
+ [...(options.expandedReasoning ?? [])].sort().join(','),
733
+ [...(options.openedGroups ?? [])].sort().join(','),
734
+ ].join('\u0000');
735
+ const cached = renderedBlockCache.get(state.blocks)?.get(key);
736
+ if (cached !== undefined)
737
+ return cached;
738
+ const blocks = state.blocks.map((block, index) => ({
739
+ block: index,
740
+ // The source text is the fallback for a block kind the painter does not lay
741
+ // out as rows of its own; matching something is better than matching nothing.
742
+ lines: renderedRows(block, theme, options).map(stripAnsi),
743
+ }));
744
+ const byKey = new Map(renderedBlockCache.get(state.blocks) ?? []);
745
+ byKey.set(key, blocks);
746
+ renderedBlockCache.set(state.blocks, byKey);
747
+ return blocks;
748
+ }
749
+ /** Plain rows one block contributes, without the search highlighting applied. */
750
+ function renderedRows(block, theme, options) {
751
+ if (block.kind === 'assistant') {
752
+ const thinkingOpen = options.hideThinking !== true || options.expandedReasoning?.has(reasoningKey(block)) === true;
753
+ return thinkingOpen && block.reasoning !== ''
754
+ ? openReasoningLines(block, theme, options.width, options.mathMode)
755
+ : assistantMarkdown(block.text, theme, options.width, {
756
+ ...(options.mathMode === undefined ? {} : { mathMode: options.mathMode }),
757
+ });
758
+ }
759
+ return blockSearchText(block).split('\n');
760
+ }
571
761
  /** Plain text of one block, used by transcript search and match painting. */
572
762
  export function blockSearchText(block) {
573
763
  if (block.kind === 'assistant') {
@@ -575,14 +765,13 @@ export function blockSearchText(block) {
575
765
  }
576
766
  if (block.kind === 'tool')
577
767
  return `${block.args}\n${block.output}${block.job === undefined ? '' : `\n${block.job.id} ${block.job.status} ${block.job.detail ?? ''}`}`;
578
- if (block.kind === 'toolCatalog') {
579
- return block.tools.map(tool => `${tool.name} ${tool.description}`).join('\n');
580
- }
581
768
  if (block.kind === 'workspace') {
582
769
  // Only the served file paths are searchable; a turn whose summary the
583
770
  // host no longer holds contributes no path text.
584
771
  return block.summary?.files.map(file => file.display).join('\n') ?? `turn ${block.turn}`;
585
772
  }
773
+ if (block.kind === 'shell')
774
+ return `${block.command}\n${block.output}`;
586
775
  return block.text;
587
776
  }
588
777
  function fitFrame(lines, width, stablePrefix = 0) {
@@ -606,35 +795,53 @@ function fitFrame(lines, width, stablePrefix = 0) {
606
795
  const transcriptBodyCache = new WeakMap();
607
796
  /** Settled immutable blocks keep their expensive Markdown/tool layout. */
608
797
  const blockLinesCache = new WeakMap();
609
- function cachedBlockLines(block, options, theme, themeName, trueColor, expanded, omitReasoning = false) {
798
+ function cachedBlockLines(block, options, theme, themeName, depth, expanded, omitReasoning = false) {
610
799
  const cached = blockLinesCache.get(block);
611
800
  if (cached !== undefined
612
801
  && cached.width === options.width
613
802
  && cached.colors === options.colors
614
- && cached.trueColor === trueColor
803
+ && cached.depth === depth
615
804
  && cached.themeName === themeName
805
+ && cached.terminalKey === terminalKey(options)
616
806
  && cached.expanded === expanded
617
807
  && cached.omitReasoning === omitReasoning
808
+ && cached.hideThinking === (options.hideThinking === true)
809
+ && cached.thinkingOpen === (block.kind === 'assistant' && options.expandedReasoning?.has(reasoningKey(block)) === true)
618
810
  && cached.mathMode === options.mathMode
619
811
  && cached.mermaidMode === options.mermaidMode
620
812
  && cached.toolDetailsKey === options.toolDetailsKey
621
- && cached.toolDetails === (block.kind === 'tool' && options.expandedTools?.has(block.callId) === true))
813
+ && cached.toolDetails === (block.kind === 'tool' && options.expandedTools?.has(block.callId) === true)
814
+ && cached.expandedOutput === (options.expandedOutputs?.has(block) === true))
622
815
  return cached.lines;
623
816
  const toolDetails = block.kind === 'tool' && options.expandedTools?.has(block.callId) === true;
624
817
  const lines = block.kind === 'tool' && toolDetails
625
818
  ? toolBlockLines(block, theme, options.width, 'details', options.toolDetailsKey)
626
- : blockLines(block, theme, options.width, { toolsExpanded: expanded, omitReasoning, toolDetailsKey: options.toolDetailsKey ?? 'Alt+O', mathMode: options.mathMode ?? 'auto', mermaidMode: options.mermaidMode ?? 'auto' });
819
+ : blockLines(block, theme, options.width, {
820
+ toolsExpanded: expanded,
821
+ omitReasoning,
822
+ toolDetailsKey: options.toolDetailsKey ?? 'Alt+O',
823
+ mathMode: options.mathMode ?? 'auto',
824
+ mermaidMode: options.mermaidMode ?? 'auto',
825
+ hideThinking: options.hideThinking === true,
826
+ thinkingOpen: block.kind === 'assistant' && options.expandedReasoning?.has(reasoningKey(block)) === true,
827
+ expandedOutput: options.expandedOutputs?.has(block) === true,
828
+ outputExpandKey: options.turnDetailsKey ?? 'Ctrl+O',
829
+ });
627
830
  blockLinesCache.set(block, {
628
831
  mathMode: options.mathMode,
629
832
  mermaidMode: options.mermaidMode,
630
833
  toolDetailsKey: options.toolDetailsKey,
631
834
  toolDetails,
835
+ expandedOutput: options.expandedOutputs?.has(block) === true,
632
836
  width: options.width,
633
837
  colors: options.colors,
634
- trueColor,
838
+ depth,
635
839
  themeName,
840
+ terminalKey: terminalKey(options),
636
841
  expanded,
637
842
  omitReasoning,
843
+ hideThinking: options.hideThinking === true,
844
+ thinkingOpen: block.kind === 'assistant' && options.expandedReasoning?.has(reasoningKey(block)) === true,
638
845
  lines,
639
846
  });
640
847
  return lines;
@@ -660,7 +867,6 @@ const PROCESS_LABELS = [
660
867
  {
661
868
  match: /^(subagent|subagent_fork|subagent_isolated|subagent_codex|subagent_claude_code|agent|ralph|workflow_run)$/, label: 'Run subagent',
662
869
  },
663
- { match: /^consult_advisor$/, label: 'Consult advisor' },
664
870
  {
665
871
  match: /^(session_search|session_trace|session_event_read|session_event_search|session_event_trace)$/,
666
872
  label: 'Search sessions',
@@ -867,29 +1073,46 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
867
1073
  const toolsExpanded = options.toolsExpanded === true;
868
1074
  const expandedTools = [...(options.expandedTools ?? [])].sort().join('\0');
869
1075
  const expandedReasoning = [...(options.expandedReasoning ?? [])].sort().join('\0');
1076
+ const hideThinking = options.hideThinking === true;
870
1077
  const openedGroups = [...(options.openedGroups ?? [])].sort().join('\0');
1078
+ // A command output the reader opened paints different rows, so the body cache
1079
+ // has to see it; without this the frame is reused and the key looks dead.
1080
+ const expandedOutputs = [...(options.expandedOutputs ?? [])].join('\u0000');
871
1081
  const themeName = options.themeName ?? 'dark';
872
- const trueColor = options.trueColor === true;
1082
+ const depth = colorDepthOf(options);
873
1083
  const animatedSpinnerFrame = state.blocks.some(block => block.kind === 'tool' && block.status === 'running')
874
1084
  ? spinnerFrame
875
1085
  : -1;
876
1086
  const search = options.transcriptSearch;
877
- const searchKey = search === undefined
878
- ? ''
879
- : `${search.query}\u0000${search.focus}\u0000${search.matches.join(',')}`;
1087
+ const searchKey = search === undefined ? '' : `${search.query}\u0000${search.focus}`;
1088
+ /*
1089
+ * The fold logic asks "does this range contain a hit", which is a question about
1090
+ * blocks; the painter asks "which cells on this row". Both are answered from one
1091
+ * occurrence list, computed from the rendered rows so a hit cannot exist in the
1092
+ * count and be missing from the paint.
1093
+ *
1094
+ * It is computed before the cache check because the composer hint reports its
1095
+ * length: a cache hit still has to say how many matches there are.
1096
+ */
1097
+ const occurrences = search === undefined || search.query.trim() === ''
1098
+ ? []
1099
+ : searchMatches(renderedBlocks(state, options), search.query);
880
1100
  const cached = transcriptBodyCache.get(state.blocks);
881
1101
  if (cached !== undefined
882
1102
  && cached.width === options.width
883
1103
  && cached.colors === options.colors
884
- && cached.trueColor === trueColor
1104
+ && cached.depth === depth
885
1105
  && cached.themeName === themeName
1106
+ && cached.terminalKey === terminalKey(options)
886
1107
  && cached.spinnerFrame === animatedSpinnerFrame
887
1108
  && cached.mathMode === options.mathMode
888
1109
  && cached.mermaidMode === options.mermaidMode
889
1110
  && cached.toolsExpanded === toolsExpanded
890
1111
  && cached.expandedTools === expandedTools
891
1112
  && cached.expandedReasoning === expandedReasoning
1113
+ && cached.hideThinking === hideThinking
892
1114
  && cached.openedGroups === openedGroups
1115
+ && cached.expandedOutputs === expandedOutputs
893
1116
  && cached.toolDetailsKey === options.toolDetailsKey
894
1117
  && cached.turnSpans === state.turnSpans
895
1118
  && cached.turnDetailsKey === options.turnDetailsKey
@@ -904,9 +1127,12 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
904
1127
  foldShape: cached.foldShape,
905
1128
  foldMarks: cached.foldMarks,
906
1129
  liveFrom: cached.liveFrom,
1130
+ // Recomputed on a cache hit from the same input, so the hint's count is
1131
+ // never a stale number left over from an earlier query.
1132
+ occurrences,
907
1133
  };
908
1134
  }
909
- const matches = new Set(search?.matches ?? []);
1135
+ const matches = new Set(occurrences.map(match => match.block));
910
1136
  const lines = [];
911
1137
  const fileRegions = [];
912
1138
  const filesAt = (index, start, length, expanded = false) => {
@@ -981,10 +1207,16 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
981
1207
  const foldMarks = [];
982
1208
  if (toolsExpanded)
983
1209
  foldMarks.push({ key: 'all', row: 0 });
1210
+ // Collapsing or revealing every thought reshapes from the top, like the
1211
+ // global tool switch above, so it reports the same kind of mark.
1212
+ if (hideThinking)
1213
+ foldMarks.push({ key: 'thinking', row: 0 });
984
1214
  let liveFrom;
985
1215
  const context = {
986
- blocks: state.blocks, theme, themeName, trueColor, width: options.width,
987
- matches, options: { ...options, expandedReasoning: openedThoughts },
1216
+ blocks: state.blocks, theme, themeName, depth, width: options.width,
1217
+ matches, occurrences, options: { ...options, expandedReasoning: openedThoughts },
1218
+ openedThoughts,
1219
+ toolCards: { count: 0 },
988
1220
  };
989
1221
  const compactProcessRow = (block) => {
990
1222
  if (block?.kind === 'notice')
@@ -1014,17 +1246,28 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1014
1246
  lines.push('');
1015
1247
  const headerRow = lines.length;
1016
1248
  const opened = openGroups.has(groupIndex);
1017
- const shape = group.live ? 'live' : opened ? 'open' : 'folded';
1249
+ /**
1250
+ * A run the reader stopped keeps the rows it had while it ran.
1251
+ *
1252
+ * Folding it at the moment of the interrupt would take those rows out from
1253
+ * under the reader, and — because a fold shortens the document — leave the
1254
+ * viewport starting inside rows already committed to native history, which
1255
+ * cannot be repainted. It stays open until the next submission, which is
1256
+ * the point at which the reader has moved on.
1257
+ */
1258
+ const stopped = group.turn !== undefined && (state.stoppedTurns ?? []).includes(group.turn);
1259
+ const shown = opened || stopped;
1260
+ const shape = group.live ? 'live' : shown ? 'open' : 'folded';
1018
1261
  foldMarks.push({ key: `run:${group.key}:${shape}`, row: headerRow });
1019
1262
  if (group.live)
1020
1263
  liveFrom ??= headerRow;
1021
1264
  // A live run has no header: it becomes a group when its turn ends.
1022
- if (!group.live && !opened) {
1265
+ if (!group.live && !shown) {
1023
1266
  lines.push(...processGroupHeader(group, theme, options.width, elapsedOf(groupIndex), options.turnDetailsKey));
1024
1267
  }
1025
1268
  const bodyRow = lines.length;
1026
1269
  let offsets = new Map();
1027
- if (group.live || opened) {
1270
+ if (group.live || shown) {
1028
1271
  const body = processGroupBody(group, context);
1029
1272
  for (const line of body.lines)
1030
1273
  lines.push(line);
@@ -1070,10 +1313,8 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1070
1313
  blockDrawStarts[group.answer] = lines.length;
1071
1314
  offsets.set(group.answer, lines.length - bodyRow);
1072
1315
  const answer = state.blocks[group.answer];
1073
- const rendered = cachedBlockLines(answer, options, theme, themeName, trueColor, toolsExpanded, absorbed.has(group.answer));
1074
- for (const line of (search !== undefined && matches.has(group.answer)
1075
- ? rendered.map(row => blockMatchesQuery(stripAnsi(row), search.query) ? theme.inverse(row) : row)
1076
- : rendered))
1316
+ const rendered = cachedBlockLines(answer, options, theme, themeName, depth, toolsExpanded, absorbed.has(group.answer));
1317
+ for (const line of paintSearch(rendered, group.answer, occurrences, theme))
1077
1318
  lines.push(line);
1078
1319
  if (thoughtHit && thoughtRow !== undefined)
1079
1320
  blockStarts[group.answer] = bodyRow + thoughtRow;
@@ -1099,7 +1340,7 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1099
1340
  offsets.set(at, lines.length - bodyRow);
1100
1341
  const rows = !group.live && !opened && trailing?.kind === 'tool'
1101
1342
  ? toolRowLine(trailing, theme, options.width, spinnerFrame)
1102
- : groupMemberLines(at, context);
1343
+ : groupMemberLines(at, context, false, 0, group.live || opened);
1103
1344
  filesAt(at, lines.length, rows.length);
1104
1345
  for (const line of rows)
1105
1346
  lines.push(line);
@@ -1161,11 +1402,9 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1161
1402
  liveFrom ??= lines.length;
1162
1403
  const expanded = (block.kind === 'workspace' && groups.some((group, at) => group.turn === block.turn && openGroups.has(at))) || matches.has(index) || toolsExpanded
1163
1404
  || (block.kind === 'tool' && options.expandedTools?.has(block.callId) === true);
1164
- const rendered = cachedBlockLines(block, options, theme, themeName, trueColor, expanded, absorbed.has(index));
1405
+ const rendered = cachedBlockLines(block, options, theme, themeName, depth, expanded, absorbed.has(index));
1165
1406
  filesAt(index, lines.length, rendered.length, expanded);
1166
- lines.push(...(search !== undefined && matches.has(index)
1167
- ? rendered.map(line => blockMatchesQuery(stripAnsi(line), search.query) ? theme.inverse(line) : line)
1168
- : rendered));
1407
+ lines.push(...paintSearch(rendered, index, occurrences, theme));
1169
1408
  blockDrawStarts[index] = blockStarts[index];
1170
1409
  previous = block;
1171
1410
  }
@@ -1181,13 +1420,16 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1181
1420
  blockDrawStarts,
1182
1421
  width: options.width,
1183
1422
  colors: options.colors,
1184
- trueColor,
1423
+ depth,
1185
1424
  themeName,
1425
+ terminalKey: terminalKey(options),
1186
1426
  spinnerFrame: animatedSpinnerFrame,
1187
1427
  toolsExpanded,
1188
1428
  expandedTools,
1189
1429
  expandedReasoning,
1430
+ hideThinking,
1190
1431
  openedGroups,
1432
+ expandedOutputs,
1191
1433
  searchKey,
1192
1434
  turnSpans: state.turnSpans,
1193
1435
  status: state.status,
@@ -1198,11 +1440,9 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1198
1440
  lines,
1199
1441
  blockStarts,
1200
1442
  });
1201
- return { lines, blockStarts, blockDrawStarts, fileRegions, foldShape, foldMarks, liveFrom };
1443
+ return { lines, blockStarts, blockDrawStarts, fileRegions, foldShape, foldMarks, liveFrom, occurrences };
1202
1444
  }
1203
1445
  function commandSurfaceName(block) {
1204
- if (block?.kind === 'toolCatalog')
1205
- return 'tools';
1206
1446
  if (block?.kind === 'commandOutput')
1207
1447
  return block.command;
1208
1448
  return undefined;
@@ -1381,6 +1621,39 @@ function todoPreviewStart(todos) {
1381
1621
  const pending = todos.findIndex(todo => todo.status === 'pending');
1382
1622
  return pending >= 0 ? pending : Math.max(0, todos.length - TODO_PREVIEW);
1383
1623
  }
1624
+ /** How many rows the pane may take before it starts scrolling. */
1625
+ const ASIDE_MAX_ROWS = 12;
1626
+ /**
1627
+ * The side-conversation pane: a rule, the question, the answer, and how to leave.
1628
+ *
1629
+ * A pane rather than a transcript block because the exchange belongs to a
1630
+ * conversation that is not this one: the reader is working in the transcript, and
1631
+ * an answer from elsewhere should not become a row of it. Docked above the
1632
+ * composer, `Esc` closes it and the session looks exactly as it did.
1633
+ */
1634
+ export function renderAside(panel, theme, width, available = ASIDE_MAX_ROWS) {
1635
+ if (width <= 0)
1636
+ return [];
1637
+ const rule = theme.fg('border', '─'.repeat(width));
1638
+ const head = ' ' + theme.bold(theme.fg('accent', '/btw')) + ' ' + theme.fg('text', panel.question);
1639
+ const body = panel.text === ''
1640
+ ? [theme.fg('dim', panel.status === 'running' ? '…' : 'No answer.')]
1641
+ : renderMarkdown(panel.text, theme, Math.max(1, width - 2));
1642
+ const detail = panel.detail === undefined || panel.detail === ''
1643
+ ? []
1644
+ : ['', theme.fg('dim', panel.detail)];
1645
+ const hint = ' ' + theme.fg('dim', formatOverlayHint([{ keys: '↑↓', action: 'scroll' }, { keys: '/copy', action: 'copy' }, { keys: 'Esc', action: 'close' }]));
1646
+ // Two rules, the question, its blank line, and the hint are always drawn. What
1647
+ // is left is the answer's window, so the pane never grows past its cap.
1648
+ const cap = Math.max(6, Math.min(ASIDE_MAX_ROWS, available));
1649
+ const windowRows = Math.max(1, cap - 4);
1650
+ const content = [...body, ...detail];
1651
+ const maxStart = Math.max(0, content.length - windowRows);
1652
+ const first = Math.max(0, Math.min(panel.scroll, maxStart));
1653
+ const rows = [head, '', ...content.slice(first, first + windowRows)];
1654
+ return [rule, ...rows.map(line => ' ' + line).map(line => truncateToWidth(line, width)),
1655
+ truncateToWidth(hint, width), rule];
1656
+ }
1384
1657
  /** Compact, unframed Todo tree placed above queued messages. */
1385
1658
  export function renderTodos(todos, theme, width) {
1386
1659
  if (todos.length === 0 || width <= 0)
@@ -1410,7 +1683,8 @@ export function renderTodos(todos, theme, width) {
1410
1683
  })];
1411
1684
  return lines.map(line => truncateToWidth(line, width));
1412
1685
  }
1413
- function subagentPreviewStart(agents) {
1686
+ /** First row the bar shows; the provider starts its selection here. */
1687
+ export function subagentPreviewStart(agents) {
1414
1688
  if (agents.length <= SUBAGENT_PREVIEW)
1415
1689
  return 0;
1416
1690
  const active = agents.findIndex(agent => agent.phase === 'running' || agent.phase === 'starting');
@@ -1434,12 +1708,24 @@ function subagentPhaseGlyph(phase, theme, spinnerFrame) {
1434
1708
  const SUBAGENT_PHASE_LABELS = {
1435
1709
  starting: 'Starting', running: 'Running', waiting: 'Waiting', completed: 'Done', error: 'Failed',
1436
1710
  };
1711
+ /** How long a child has been (or was) running, as one label. */
1712
+ export function formatAgentElapsed(agent, now = Date.now()) {
1713
+ if (agent.startedAt === undefined)
1714
+ return '—';
1715
+ const live = agent.phase === 'running' || agent.phase === 'starting';
1716
+ const ms = Math.max(0, (live ? now : agent.updatedAt ?? now) - agent.startedAt);
1717
+ if (ms < 1_000)
1718
+ return `${Math.round(ms)}ms`;
1719
+ if (ms < 60_000)
1720
+ return `${(ms / 1_000).toFixed(ms < 10_000 ? 1 : 0)}s`;
1721
+ return `${Math.floor(ms / 60_000)}m${String(Math.floor((ms % 60_000) / 1_000)).padStart(2, '0')}s`;
1722
+ }
1437
1723
  function subagentRowText(agent) {
1438
1724
  const indent = agent.depth > 1 ? ' '.repeat(agent.depth - 1) : '';
1439
1725
  return `${indent}${agent.label} · ${SUBAGENT_PHASE_LABELS[agent.phase]}`;
1440
1726
  }
1441
1727
  /** Compact, unframed descendant-subagent tree placed above Todos. */
1442
- export function renderSubagents(roster, theme, width, spinnerFrame = 0, inspectedId, launcherFocused = false) {
1728
+ export function renderSubagents(roster, theme, width, spinnerFrame = 0, inspectedId, selected) {
1443
1729
  const agents = roster?.agents ?? [];
1444
1730
  if (agents.length === 0 || width <= 0)
1445
1731
  return [];
@@ -1453,10 +1739,16 @@ export function renderSubagents(roster, theme, width, spinnerFrame = 0, inspecte
1453
1739
  done === 0 ? undefined : `${done} done`,
1454
1740
  failed === 0 ? undefined : `${failed} failed`,
1455
1741
  ].filter((part) => part !== undefined);
1742
+ const pointing = selected !== undefined;
1456
1743
  const headerBody = theme.bold(theme.fg('accent', 'Agents'))
1457
1744
  + (counts.length === 0 ? '' : theme.fg('dim', ` · ${counts.join(' · ')}`))
1458
- + theme.fg('dim', launcherFocused ? ' · Enter open · Esc return' : ' · ↓ select · Alt+A open');
1459
- const header = ' ' + (launcherFocused ? theme.inverse(headerBody) : headerBody);
1745
+ // Short enough to survive a narrow bar: the counts already use the width.
1746
+ + theme.fg('dim', pointing ? ' · ↑↓ · Enter open · Esc' : ' · ↓ select');
1747
+ const header = ' ' + (pointing ? theme.inverse(headerBody) : headerBody);
1748
+ // A workflow run belongs to the same roster: it is the one place a reader learns
1749
+ // that a workflow is still going, so it stays as the bar's first row rather than
1750
+ // living in the screen this bar replaced.
1751
+ const workflows = (roster?.workflows ?? []).map(run => ' ' + theme.fg(run.status === 'completed' ? 'success' : run.status === 'open' ? 'accent' : 'warning', `Workflow · ${run.name} · ${run.status}${run.phase === undefined ? '' : ` · ${run.phase}`}`));
1460
1752
  const start = subagentPreviewStart(agents);
1461
1753
  const end = Math.min(agents.length, start + SUBAGENT_PREVIEW);
1462
1754
  const rows = [
@@ -1464,11 +1756,11 @@ export function renderSubagents(roster, theme, width, spinnerFrame = 0, inspecte
1464
1756
  ...agents.slice(start, end),
1465
1757
  ...(end === agents.length ? [] : [`… ${agents.length - end} more`]),
1466
1758
  ];
1467
- const lines = [header, ...rows.map((row, index) => {
1759
+ const lines = [header, ...workflows, ...rows.map((row, index) => {
1468
1760
  const branch = ' ' + theme.fg('dim', index === rows.length - 1 ? '└─' : '├─') + ' ';
1469
1761
  if (typeof row === 'string')
1470
1762
  return branch + theme.fg('dim', row);
1471
- const selected = inspectedId === row.id;
1763
+ const current = inspectedId === row.id;
1472
1764
  const paint = row.phase === 'completed'
1473
1765
  ? (text) => theme.fg('dim', theme.strikethrough(text))
1474
1766
  : row.phase === 'error'
@@ -1476,9 +1768,10 @@ export function renderSubagents(roster, theme, width, spinnerFrame = 0, inspecte
1476
1768
  : row.phase === 'waiting'
1477
1769
  ? (text) => theme.fg('dim', text)
1478
1770
  : (text) => text;
1479
- const marker = selected ? theme.fg('accent', SYMBOL.cursor) + ' ' : '';
1771
+ const pointed = index === selected;
1772
+ const marker = current || pointed ? theme.fg('accent', SYMBOL.cursor) + ' ' : '';
1480
1773
  const body = marker + subagentPhaseGlyph(row.phase, theme, spinnerFrame) + ' ' + paint(subagentRowText(row));
1481
- return branch + (selected ? theme.bold(body) : body);
1774
+ return branch + (current || pointed ? theme.bold(body) : body);
1482
1775
  })];
1483
1776
  return lines.map(line => truncateToWidth(line, width));
1484
1777
  }
@@ -1571,9 +1864,12 @@ export function renderQueuedSubmissions(submissions, theme, width, inbox = []) {
1571
1864
  const editSingleHint = width >= 72 ? '↑/Alt+Up edit · Alt+Q queue' : '↑ edit';
1572
1865
  const editMultiHint = width >= 72 ? '↑/Alt+Up edit latest · Alt+Q queue' : '↑ edit latest';
1573
1866
  if (labels.length === 1) {
1574
- return [alignAction(rail + queueLabel + theme.fg('dim', ' · ') + theme.fg('dim', 'next turn · ') + theme.fg('text', labels[0] ?? ''), theme.fg('dim', editSingleHint))];
1867
+ return [alignAction(
1868
+ // Pi's vocabulary: what Enter typed was a follow-up, and the row says so
1869
+ // in the same word the composer hint uses.
1870
+ rail + queueLabel + theme.fg('dim', ' · ') + theme.fg('dim', 'follow-up · ') + theme.fg('text', labels[0] ?? ''), theme.fg('dim', editSingleHint))];
1575
1871
  }
1576
- const summary = ` · ${labels.length} · next turn${hidden === 0 ? '' : ` · ${hidden} earlier`}`;
1872
+ const summary = ` · ${labels.length} · follow-up${hidden === 0 ? '' : ` · ${hidden} earlier`}`;
1577
1873
  const visibleLabels = labels.slice(start);
1578
1874
  const lines = [
1579
1875
  alignAction(rail + queueLabel + theme.fg('dim', summary), theme.fg('dim', editMultiHint), false),
@@ -1583,14 +1879,14 @@ export function renderQueuedSubmissions(submissions, theme, width, inbox = []) {
1583
1879
  return lines.map(line => truncateToWidth(line, width));
1584
1880
  }
1585
1881
  /**
1586
- * Compose the welcome card, transcript, working row, and rounded editor into
1882
+ * Compose the startup header, transcript, working row, and ruled editor into
1587
1883
  * one frame — the oh-my-pi surface.
1588
1884
  * @param state - transcript state.
1589
1885
  * @param options - terminal geometry and input state.
1590
1886
  * @returns the frame to hand to a renderer.
1591
1887
  */
1592
1888
  export function renderView(state, options) {
1593
- const theme = createTheme(options.colors, options.trueColor === true, options.themeName ?? 'dark');
1889
+ const theme = createTheme(options.colors === true, options.colorDepth ?? 'truecolor', options.themeName ?? 'dark', options.terminal);
1594
1890
  const width = options.width;
1595
1891
  const height = options.height;
1596
1892
  const appName = options.appName ?? 'omdsh';
@@ -1598,16 +1894,6 @@ export function renderView(state, options) {
1598
1894
  const pwd = options.pwd ?? '';
1599
1895
  const motion = options.motion ?? 'full';
1600
1896
  const spinnerFrame = motion === 'off' ? -1 : options.spinnerFrame ?? 0;
1601
- if (options.agentHub !== undefined) {
1602
- const hub = renderAgentHub(options.agentHub, theme, width, height);
1603
- return {
1604
- lines: fitFrame(hub.lines, width),
1605
- cursor: hub.cursor,
1606
- cursorVisible: hub.cursorVisible,
1607
- // A full-screen surface drew no transcript body.
1608
- documentRows: null,
1609
- };
1610
- }
1611
1897
  if (options.trajectory !== undefined) {
1612
1898
  const trajectory = renderTrajectory(options.trajectory, theme, width, height);
1613
1899
  return {
@@ -1628,17 +1914,6 @@ export function renderView(state, options) {
1628
1914
  documentRows: null,
1629
1915
  };
1630
1916
  }
1631
- if (options.promptSelector?.request.presentation === 'fullscreen-list' || options.promptSelector?.request.presentation === 'fullscreen-tree') {
1632
- const selector = (options.promptSelector.request.presentation === 'fullscreen-tree' ? renderPromptTreePage : renderPromptSelectorPage)(options.promptSelector, theme, width, height, options.input, options.inputCursor, appName);
1633
- return {
1634
- lines: fitFrame(selector.lines, width),
1635
- ...(selector.document === undefined ? {} : { promptDocument: selector.document }),
1636
- cursor: selector.cursor,
1637
- cursorVisible: true,
1638
- // A full-screen surface drew no transcript body.
1639
- documentRows: null,
1640
- };
1641
- }
1642
1917
  if (options.promptSelector?.request.presentation === 'plan-review' || options.promptSelector?.request.presentation === 'document') {
1643
1918
  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);
1644
1919
  return {
@@ -1653,12 +1928,8 @@ export function renderView(state, options) {
1653
1928
  }
1654
1929
  const welcome = renderWelcome({
1655
1930
  width,
1656
- model: options.model,
1657
- ...(options.reasoningEffort === undefined ? {} : { reasoningEffort: options.reasoningEffort }),
1658
1931
  version,
1659
- appName,
1660
- ...(options.recentSessions === undefined ? {} : { recentSessions: options.recentSessions }),
1661
- ...(options.welcomeTips === undefined ? {} : { tips: options.welcomeTips }),
1932
+ appName: options.displayName ?? appName,
1662
1933
  }, theme);
1663
1934
  const transcript = renderTranscriptBody(state, options, theme, spinnerFrame);
1664
1935
  const body = [...welcome];
@@ -1669,29 +1940,37 @@ export function renderView(state, options) {
1669
1940
  transcriptStart = body.length;
1670
1941
  body.push(...transcript.lines);
1671
1942
  }
1672
- const working = state.status === 'running'
1673
- ? renderWorking(theme, Math.max(0, spinnerFrame), undefined, width, motion)
1943
+ // The activity is drawn inside the composer frame rather than as its own row.
1944
+ // A separate row cost a line, read as another component, and moved when the
1945
+ // composer grew; the frame is where the reader is already looking.
1946
+ const activityLabelText = state.status === 'running'
1947
+ ? options.workingLabel ?? DEEP_DRIVING_LABEL
1674
1948
  : state.status === 'compacting'
1675
- ? renderWorking(theme, Math.max(0, spinnerFrame), 'Compacting', width, motion)
1676
- : [];
1949
+ ? options.workingLabel ?? 'Compacting'
1950
+ : undefined;
1951
+ const working = [];
1677
1952
  const statusBar = resolveStatusBarConfig(options.statusBar, options.statusPreset);
1678
1953
  const placeholder = options.inspected === undefined && state.status === 'idle'
1679
1954
  && options.input === '' && (options.inputImages ?? 0) === 0
1680
1955
  ? options.modelReady === false
1681
1956
  ? 'Run /login to connect a model'
1682
- : 'Ask anything · / commands · @ files'
1957
+ // `/ commands` and `@ files` are already on the header's hint line, and this
1958
+ // one is the copy that costs every frame: the header's scrolls into history
1959
+ // once the conversation starts, this one does not. A bare prompt is enough
1960
+ // to say the composer is ready for words.
1961
+ : 'Ask anything'
1683
1962
  : null;
1684
1963
  const inlineHint = options.transcriptSearch !== undefined
1685
1964
  ? transcriptSearchHint({
1686
1965
  query: options.transcriptSearch.query,
1687
1966
  editing: options.transcriptSearch.editing,
1688
1967
  focus: options.transcriptSearch.focus,
1689
- }, options.transcriptSearch.matches.length)
1968
+ }, transcript.occurrences.length)
1690
1969
  : options.inspected === undefined
1691
1970
  ? slashInlineHint(options.input, options.inputCursor, options.commands)
1692
1971
  ?? placeholder
1693
1972
  ?? (state.status === 'running' && options.steerKey !== undefined
1694
- ? `Enter: next turn · ${options.steerKey}: next step` : null)
1973
+ ? `Enter: follow-up · ${options.steerKey}: next step` : null)
1695
1974
  : options.inspected.writable
1696
1975
  ? slashInlineHint(options.input, options.inputCursor, options.commands) ?? 'Enter to steer · Esc to return'
1697
1976
  : 'Read-only · Esc to return';
@@ -1714,29 +1993,52 @@ export function renderView(state, options) {
1714
1993
  inputCursor: options.inputCursor,
1715
1994
  status: ' ' + theme.fg('accent', '🐳') + ' ',
1716
1995
  ...(permissionBadge === '' ? {} : { statusRight: ' ' + permissionBadge + ' ' }),
1717
- border: options.inspected !== undefined && options.inspected.writable !== true
1718
- || state.status === 'idle'
1719
- ? 'border'
1720
- : 'accent',
1996
+ // A composer starting with `!` is not talking to the model, and the border
1997
+ // says so the moment the character is typed — before the reader commits to
1998
+ // running anything. Green is the one ink that means "this runs on your
1999
+ // machine", which is also what a successful tool card uses.
2000
+ border: options.input.trimStart().startsWith('!')
2001
+ ? 'shellMode'
2002
+ : options.inspected !== undefined && options.inspected.writable !== true
2003
+ || state.status === 'idle'
2004
+ ? 'border'
2005
+ : 'accent',
1721
2006
  ...(theme.colors && (leadingSlashCommandNameRange(options.input) !== null
1722
2007
  || (options.inputImages !== undefined && options.inputImages !== 0))
1723
2008
  ? { paintInput: (text, start) => paintComposerInputSlice(options.input, text, start, theme) }
1724
2009
  : {}),
1725
2010
  ...(inlineHint !== null ? { inlineHint } : {}),
2011
+ ...(options.transientStatus === undefined ? {} : { note: options.transientStatus }),
2012
+ ...(activityLabelText === undefined ? {} : {
2013
+ activity: {
2014
+ frame: motion === 'off' ? SYMBOL.running : SPINNER[Math.max(0, spinnerFrame) % SPINNER.length] ?? SYMBOL.running,
2015
+ label: activityLabelText,
2016
+ shimmer: activityLabelText === DEEP_DRIVING_LABEL && motion === 'full',
2017
+ },
2018
+ spinnerFrame: Math.max(0, spinnerFrame),
2019
+ }),
1726
2020
  };
1727
- const promptSelector = options.promptSelector === undefined
2021
+ /**
2022
+ * A tree is not a page.
2023
+ *
2024
+ * It used to take the whole frame, which hid the conversation the reader was
2025
+ * about to rewind to — the one thing they need to see while choosing a point in
2026
+ * it. Bounded to a little over half the screen it sits above the composer with
2027
+ * the transcript still behind it, the way pi mounts its tree selector.
2028
+ */
2029
+ const treeRows = options.promptSelector === undefined || options.promptSelector.request.presentation !== 'tree'
2030
+ ? undefined
2031
+ : renderPromptTreePage(options.promptSelector, theme, width, Math.max(10, Math.min(height - 8, Math.floor(height * 0.6))), options.input, options.inputCursor, appName);
2032
+ const promptSelector = treeRows ?? (options.promptSelector === undefined
1728
2033
  ? undefined
1729
- : renderPromptSelector(options.promptSelector, theme, width, options.input, options.inputCursor, Math.max(3, Math.min(10, height - working.length - 14)));
2034
+ : renderPromptSelector(options.promptSelector, theme, width, options.input, options.inputCursor, Math.max(3, Math.min(10, height - working.length - 14))));
1730
2035
  const settings = promptSelector !== undefined || options.settings === undefined
1731
2036
  ? undefined
1732
2037
  : renderSettings(options.settings, theme, width);
1733
- const copySelector = promptSelector !== undefined || settings !== undefined || options.copySelector === undefined
1734
- ? undefined
1735
- : renderCopySelector(options.copySelector, theme, width);
1736
- const search = promptSelector !== undefined || settings !== undefined || copySelector !== undefined || options.historySearch === undefined
2038
+ const search = promptSelector !== undefined || settings !== undefined || options.historySearch === undefined
1737
2039
  ? undefined
1738
2040
  : renderHistorySearch(options.historySearch, theme, width, Math.max(1, Math.min(HISTORY_SEARCH_MAX_VISIBLE, height - working.length - 8)));
1739
- const editor = promptSelector === undefined && settings === undefined && copySelector === undefined && search === undefined
2041
+ const editor = promptSelector === undefined && settings === undefined && search === undefined
1740
2042
  ? renderEditor(editorOpts, theme)
1741
2043
  : undefined;
1742
2044
  const queuedSubmissions = editor === undefined || options.inspected !== undefined
@@ -1751,6 +2053,9 @@ export function renderView(state, options) {
1751
2053
  ? []
1752
2054
  : renderTurnError(state.turnError, theme, width);
1753
2055
  const todos = editor === undefined || options.inspected !== undefined ? [] : renderTodos(state.todos, theme, width);
2056
+ const aside = editor === undefined || options.inspected !== undefined || options.aside === undefined
2057
+ ? []
2058
+ : renderAside(options.aside, theme, width, Math.max(4, Math.floor(height / 2)));
1754
2059
  const goal = editor === undefined || options.inspected !== undefined || options.sessionControls?.goal === undefined
1755
2060
  ? []
1756
2061
  : renderGoalBar(options.sessionControls.goal, theme, width);
@@ -1761,15 +2066,15 @@ export function renderView(state, options) {
1761
2066
  const inspect = editor === undefined ? [] : renderInspectBanner(options.inspected, theme, width, spinnerFrame);
1762
2067
  const subagents = editor === undefined
1763
2068
  ? []
1764
- : renderSubagents(options.subagents, theme, width, spinnerFrame, options.inspected?.id, options.subagentLauncherFocused);
1765
- const autocomplete = promptSelector !== undefined || settings !== undefined || copySelector !== undefined || search !== undefined
2069
+ : renderSubagents(options.subagents, theme, width, spinnerFrame, options.inspected?.id, options.subagentSelection);
2070
+ const autocomplete = promptSelector !== undefined || settings !== undefined || search !== undefined
1766
2071
  || options.autocomplete === undefined
1767
2072
  ? []
1768
2073
  : renderAutocomplete(options.autocomplete.items, options.autocomplete.selected, theme, width);
1769
- const inputLines = promptSelector?.lines ?? settings?.lines ?? copySelector?.lines ?? search?.lines
2074
+ const inputLines = promptSelector?.lines ?? settings?.lines ?? search?.lines
1770
2075
  ?? (editor === undefined ? [] : editor.lines);
1771
2076
  const spacer = 1;
1772
- 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;
2077
+ const reserved = aside.length + inputLines.length + working.length + inspect.length + subagents.length + todos.length + goal.length + questions.length + queuedSubmissions.length + turnError.length + spacer + autocomplete.length + statusFooter.length;
1773
2078
  const budget = Math.max(0, height - reserved);
1774
2079
  const focusIndex = options.focusBlock === undefined
1775
2080
  ? undefined
@@ -1783,7 +2088,7 @@ export function renderView(state, options) {
1783
2088
  const requestedStart = focusStart === undefined
1784
2089
  ? (options.scrollStart ?? Number.POSITIVE_INFINITY)
1785
2090
  : transcriptStart + focusStart;
1786
- const hasOverlay = promptSelector !== undefined || settings !== undefined || copySelector !== undefined || search !== undefined;
2091
+ const hasOverlay = promptSelector !== undefined || settings !== undefined || search !== undefined;
1787
2092
  const isFollowing = requestedStart === Number.POSITIVE_INFINITY && !hasOverlay;
1788
2093
  // A run still at work reshapes as it goes — its newest rows scroll, it folds
1789
2094
  // when the turn ends — so none of it may freeze into history until it
@@ -1803,9 +2108,29 @@ export function renderView(state, options) {
1803
2108
  if (visible.length > 0)
1804
2109
  lines.push(showJump
1805
2110
  ? ' '.repeat(jumpColumn) + theme.inverse(jumpText) : '');
1806
- const bottomRows = working.length + goal.length + questions.length + inspect.length + subagents.length + todos.length + queuedSubmissions.length + turnError.length + inputLines.length + autocomplete.length + statusFooter.length;
2111
+ const bottomRows = aside.length + working.length + goal.length + questions.length + inspect.length + subagents.length + todos.length + queuedSubmissions.length + turnError.length + inputLines.length + autocomplete.length + statusFooter.length;
1807
2112
  const fill = Math.max(0, height - lines.length - bottomRows);
1808
- lines.push(...Array.from({ length: fill }, () => ''));
2113
+ /**
2114
+ * Spare rows sit between the header and the conversation while at the tail.
2115
+ *
2116
+ * A short transcript used to leave its slack below the last row, so folding a
2117
+ * run — which shortens the transcript — widened a gap in the middle of the
2118
+ * screen while nothing the reader was looking at moved. Moving the slack to the
2119
+ * top of the *frame* fixed that but dragged the header down with it, and a
2120
+ * header that drops to the bottom the moment the first message arrives is worse
2121
+ * than the gap.
2122
+ *
2123
+ * Between the header and the transcript, both stay put: the header keeps the
2124
+ * row it is read on, the newest rows keep the row they are watched on, and the
2125
+ * slack absorbs whatever a fold removes.
2126
+ *
2127
+ * Not while scrolled: a window is already full, and padding a scrollback view
2128
+ * would shift the rows the reader deliberately positioned.
2129
+ */
2130
+ const topPad = windowed === undefined && transcript.lines.length > 0 ? fill : 0;
2131
+ if (topPad === 0)
2132
+ lines.push(...Array.from({ length: fill }, () => ''));
2133
+ lines.push(...aside);
1809
2134
  lines.push(...working);
1810
2135
  lines.push(...goal);
1811
2136
  lines.push(...questions);
@@ -1826,10 +2151,10 @@ export function renderView(state, options) {
1826
2151
  ? pendingRow
1827
2152
  : Math.min(pendingRow ?? transcript.liveFrom, transcript.liveFrom);
1828
2153
  if (liveRow !== undefined) {
1829
- liveStart = transcriptStart + liveRow;
2154
+ liveStart = transcriptStart + liveRow + topPad;
1830
2155
  }
1831
2156
  else {
1832
- liveStart = lines.length - bottomRows;
2157
+ liveStart = lines.length - bottomRows + topPad;
1833
2158
  }
1834
2159
  // liveStart marks the first row that is still live/mutable. Rows above it
1835
2160
  // are committed. Off-screen live rows are bounded by the renderer: it only
@@ -1839,18 +2164,23 @@ export function renderView(state, options) {
1839
2164
  else {
1840
2165
  liveStart = 0;
1841
2166
  }
2167
+ // Padding rows are not document rows: `frameStart` below tells the renderer
2168
+ // which frame row the document begins at, so they never map to content.
2169
+ const frameLines = topPad === 0
2170
+ ? lines
2171
+ : [...lines.slice(0, transcriptStart), ...Array.from({ length: topPad }, () => ''), ...lines.slice(transcriptStart)];
1842
2172
  // Every settled block was already rendered against this width. Mirroring
1843
2173
  // OMP's stable-prefix preparation, only validate the mutable suffix instead
1844
2174
  // of measuring the complete native-scrollback history on every frame.
1845
- const trimmed = fitFrame(lines, width, isFollowing ? liveStart : 0);
1846
- const caret = promptSelector?.cursor ?? settings?.cursor ?? copySelector?.cursor ?? search?.cursor ?? editor?.cursor ?? { row: 0, column: 0 };
2175
+ const trimmed = fitFrame(frameLines, width, isFollowing ? liveStart + topPad : 0);
2176
+ const caret = promptSelector?.cursor ?? settings?.cursor ?? search?.cursor ?? editor?.cursor ?? { row: 0, column: 0 };
1847
2177
  // Which document rows this frame's body came from. `body` already includes the
1848
2178
  // header, so a window's own range is already in document coordinates and must
1849
2179
  // not have the header added again. No window means the whole body was drawn —
1850
2180
  // that is a known range, not a frame that failed to report one, and treating
1851
2181
  // it as "no body" would clear the record on every ordinary follow frame.
1852
2182
  const windowRange = windowed === undefined
1853
- ? (body.length === 0 ? null : { documentStart: 0, documentEnd: body.length, frameStart: 0 })
2183
+ ? (body.length === 0 ? null : { documentStart: 0, documentEnd: body.length + topPad, frameStart: 0 })
1854
2184
  : windowed.documentRows;
1855
2185
  const bodySource = windowRange === undefined
1856
2186
  ? null
@@ -1863,11 +2193,11 @@ export function renderView(state, options) {
1863
2193
  continue;
1864
2194
  const previous = stickyHeaders.at(-1);
1865
2195
  if (previous !== undefined)
1866
- previous.end = transcriptStart + transcript.blockStarts[at];
2196
+ previous.end = transcriptStart + topPad + transcript.blockStarts[at];
1867
2197
  const padding = width > 4 ? 2 : 0;
1868
2198
  const label = truncateToWidth(promptSummary(block), Math.max(1, width - padding * 2));
1869
2199
  stickyHeaders.push({
1870
- start: transcriptStart + (transcript.blockStarts[at + 1] ?? transcript.lines.length),
2200
+ start: transcriptStart + topPad + (transcript.blockStarts[at + 1] ?? transcript.lines.length),
1871
2201
  end: body.length,
1872
2202
  text: paintSurface(theme, 'userMessageBg', 'userMessageText', padToWidth(' '.repeat(padding) + label, width)),
1873
2203
  });
@@ -1876,16 +2206,20 @@ export function renderView(state, options) {
1876
2206
  return {
1877
2207
  lines: trimmed,
1878
2208
  stickyHeaders,
1879
- ...(jumpToLatest === undefined ? {} : { jumpToLatest }),
2209
+ ...(jumpToLatest === undefined ? {} : { jumpToLatest: { ...jumpToLatest, row: jumpToLatest.row + topPad } }),
2210
+ // The tree's preview is a scrollable document too: without its coordinates
2211
+ // the advertised Ctrl+↑↓ had nothing to move, because the card path never
2212
+ // reported them.
2213
+ ...(promptSelector?.document === undefined ? {} : { promptDocument: promptSelector.document }),
1880
2214
  documentRows: bodySource,
1881
2215
  cursor: {
1882
- row: editorStart + caret.row,
2216
+ row: editorStart + caret.row + topPad,
1883
2217
  column: Math.min(caret.column, width),
1884
2218
  },
1885
2219
  cursorVisible: state.status === 'compacting'
1886
2220
  || (options.inspected !== undefined && options.inspected.writable !== true)
1887
2221
  ? false
1888
- : promptSelector?.cursorVisible ?? (settings === undefined && copySelector === undefined),
2222
+ : promptSelector?.cursorVisible ?? (settings === undefined),
1889
2223
  liveStart,
1890
2224
  livePinned,
1891
2225
  // A transient frame is either a full-screen surface or the user browsing
@@ -1901,11 +2235,11 @@ export function renderView(state, options) {
1901
2235
  hiddenBelow: 0,
1902
2236
  }),
1903
2237
  blockStarts: transcript.blockStarts,
1904
- fileRegions: transcript.fileRegions.map(region => ({ ...region, start: transcriptStart + region.start, end: transcriptStart + region.end })),
2238
+ fileRegions: transcript.fileRegions.map(region => ({ ...region, start: transcriptStart + topPad + region.start, end: transcriptStart + topPad + region.end })),
1905
2239
  blockDrawStarts: transcript.blockDrawStarts,
1906
2240
  foldShape: transcript.foldShape,
1907
2241
  foldMarks: transcript.foldMarks,
1908
- bodyRow: transcriptStart,
2242
+ bodyRow: transcriptStart + topPad,
1909
2243
  },
1910
2244
  };
1911
2245
  }