@agi-fans/dsh-tui 0.20.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 (319) hide show
  1. package/lib/CHANGELOG.md +249 -3
  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 +18 -0
  12. package/lib/chrome/main-screen-renderer.js +58 -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/renderer.d.ts +9 -0
  20. package/lib/chrome/renderer.js +1 -1
  21. package/lib/chrome/renderer.js.map +1 -1
  22. package/lib/chrome/secret-mask.d.ts +35 -0
  23. package/lib/chrome/secret-mask.js +180 -0
  24. package/lib/chrome/secret-mask.js.map +1 -0
  25. package/lib/chrome/status-config.d.ts +161 -51
  26. package/lib/chrome/status-config.js +237 -126
  27. package/lib/chrome/status-config.js.map +1 -1
  28. package/lib/chrome/status-line.d.ts +3 -2
  29. package/lib/chrome/status-line.js +105 -63
  30. package/lib/chrome/status-line.js.map +1 -1
  31. package/lib/chrome/terminal-colors.d.ts +85 -0
  32. package/lib/chrome/terminal-colors.js +230 -0
  33. package/lib/chrome/terminal-colors.js.map +1 -0
  34. package/lib/chrome/terminal-images.d.ts +9 -1
  35. package/lib/chrome/terminal-images.js +27 -4
  36. package/lib/chrome/terminal-images.js.map +1 -1
  37. package/lib/chrome/theme.d.ts +32 -9
  38. package/lib/chrome/theme.js +616 -520
  39. package/lib/chrome/theme.js.map +1 -1
  40. package/lib/chrome/tool-args.d.ts +1 -1
  41. package/lib/chrome/tool-args.js +3 -1
  42. package/lib/chrome/tool-args.js.map +1 -1
  43. package/lib/chrome/tool-renderers.d.ts +2 -0
  44. package/lib/chrome/tool-renderers.js +69 -3
  45. package/lib/chrome/tool-renderers.js.map +1 -1
  46. package/lib/chrome/tools-list.d.ts +1 -4
  47. package/lib/chrome/tools-list.js +1 -41
  48. package/lib/chrome/tools-list.js.map +1 -1
  49. package/lib/chrome/welcome-tips.d.ts +20 -7
  50. package/lib/chrome/welcome-tips.js +22 -41
  51. package/lib/chrome/welcome-tips.js.map +1 -1
  52. package/lib/commands/auth-ids.d.ts +25 -0
  53. package/lib/commands/auth-ids.js +27 -0
  54. package/lib/commands/auth-ids.js.map +1 -0
  55. package/lib/commands/auth-input.d.ts +34 -0
  56. package/lib/commands/auth-input.js +96 -0
  57. package/lib/commands/auth-input.js.map +1 -0
  58. package/lib/commands/auth-providers.d.ts +39 -0
  59. package/lib/commands/auth-providers.js +125 -0
  60. package/lib/commands/auth-providers.js.map +1 -0
  61. package/lib/commands/auth.d.ts +16 -22
  62. package/lib/commands/auth.js +346 -299
  63. package/lib/commands/auth.js.map +1 -1
  64. package/lib/commands/btw.d.ts +27 -0
  65. package/lib/commands/btw.js +59 -0
  66. package/lib/commands/btw.js.map +1 -0
  67. package/lib/commands/diff.js +30 -5
  68. package/lib/commands/diff.js.map +1 -1
  69. package/lib/commands/export.d.ts +1 -1
  70. package/lib/commands/export.js +5 -3
  71. package/lib/commands/export.js.map +1 -1
  72. package/lib/commands/files.d.ts +17 -1
  73. package/lib/commands/files.js +69 -61
  74. package/lib/commands/files.js.map +1 -1
  75. package/lib/commands/integrations.js +18 -6
  76. package/lib/commands/integrations.js.map +1 -1
  77. package/lib/commands/jobs.d.ts +19 -4
  78. package/lib/commands/jobs.js +50 -7
  79. package/lib/commands/jobs.js.map +1 -1
  80. package/lib/commands/model.js +109 -76
  81. package/lib/commands/model.js.map +1 -1
  82. package/lib/commands/permission.js +46 -13
  83. package/lib/commands/permission.js.map +1 -1
  84. package/lib/commands/plugins.js +196 -42
  85. package/lib/commands/plugins.js.map +1 -1
  86. package/lib/commands/schedule.d.ts +29 -0
  87. package/lib/commands/schedule.js +228 -0
  88. package/lib/commands/schedule.js.map +1 -0
  89. package/lib/commands/session-configuration.d.ts +1 -1
  90. package/lib/commands/session-configuration.js +29 -28
  91. package/lib/commands/session-configuration.js.map +1 -1
  92. package/lib/commands/session.js +75 -80
  93. package/lib/commands/session.js.map +1 -1
  94. package/lib/commands/terminal.d.ts +24 -4
  95. package/lib/commands/terminal.js +38 -94
  96. package/lib/commands/terminal.js.map +1 -1
  97. package/lib/commands/trajectory.js +4 -1
  98. package/lib/commands/trajectory.js.map +1 -1
  99. package/lib/definition.d.ts +150 -11
  100. package/lib/index.d.ts +1 -0
  101. package/lib/index.js +1 -0
  102. package/lib/index.js.map +1 -1
  103. package/lib/input/actions.d.ts +51 -0
  104. package/lib/input/actions.js +120 -0
  105. package/lib/input/actions.js.map +1 -0
  106. package/lib/input/editor-discovery.js +2 -2
  107. package/lib/input/editor-discovery.js.map +1 -1
  108. package/lib/input/editor.d.ts +0 -10
  109. package/lib/input/editor.js +11 -9
  110. package/lib/input/editor.js.map +1 -1
  111. package/lib/input/image-paste.d.ts +19 -1
  112. package/lib/input/image-paste.js +82 -1
  113. package/lib/input/image-paste.js.map +1 -1
  114. package/lib/input/keybindings-config.d.ts +15 -4
  115. package/lib/input/keybindings-config.js +57 -35
  116. package/lib/input/keybindings-config.js.map +1 -1
  117. package/lib/input/keys.d.ts +18 -0
  118. package/lib/input/keys.js +48 -3
  119. package/lib/input/keys.js.map +1 -1
  120. package/lib/input/wheel-scroll.d.ts +46 -0
  121. package/lib/input/wheel-scroll.js +69 -0
  122. package/lib/input/wheel-scroll.js.map +1 -0
  123. package/lib/prompt-intents.d.ts +182 -0
  124. package/lib/prompt-intents.js +127 -0
  125. package/lib/prompt-intents.js.map +1 -0
  126. package/lib/runtime/agent-profile.d.ts +5 -0
  127. package/lib/runtime/agent-profile.js +12 -0
  128. package/lib/runtime/agent-profile.js.map +1 -1
  129. package/lib/runtime/approval-rules.d.ts +59 -0
  130. package/lib/runtime/approval-rules.js +179 -0
  131. package/lib/runtime/approval-rules.js.map +1 -0
  132. package/lib/runtime/btw-policy.d.ts +7 -0
  133. package/lib/runtime/btw-policy.js +18 -0
  134. package/lib/runtime/btw-policy.js.map +1 -0
  135. package/lib/runtime/btw.d.ts +47 -0
  136. package/lib/runtime/btw.js +200 -0
  137. package/lib/runtime/btw.js.map +1 -0
  138. package/lib/runtime/cancellable.d.ts +75 -0
  139. package/lib/runtime/cancellable.js +76 -0
  140. package/lib/runtime/cancellable.js.map +1 -0
  141. package/lib/runtime/composer-images.js +5 -4
  142. package/lib/runtime/composer-images.js.map +1 -1
  143. package/lib/runtime/composer-shell.d.ts +30 -0
  144. package/lib/runtime/composer-shell.js +150 -0
  145. package/lib/runtime/composer-shell.js.map +1 -0
  146. package/lib/runtime/file-recovery-local.d.ts +73 -0
  147. package/lib/runtime/file-recovery-local.js +351 -0
  148. package/lib/runtime/file-recovery-local.js.map +1 -0
  149. package/lib/runtime/file-recovery-recording.d.ts +23 -0
  150. package/lib/runtime/file-recovery-recording.js +131 -0
  151. package/lib/runtime/file-recovery-recording.js.map +1 -0
  152. package/lib/runtime/file-recovery-review.d.ts +3 -0
  153. package/lib/runtime/file-recovery-review.js +130 -0
  154. package/lib/runtime/file-recovery-review.js.map +1 -0
  155. package/lib/runtime/file-recovery-store.d.ts +83 -0
  156. package/lib/runtime/file-recovery-store.js +329 -0
  157. package/lib/runtime/file-recovery-store.js.map +1 -0
  158. package/lib/runtime/file-recovery-worker.d.ts +22 -0
  159. package/lib/runtime/file-recovery-worker.js +153 -0
  160. package/lib/runtime/file-recovery-worker.js.map +1 -0
  161. package/lib/runtime/file-review.d.ts +6 -3
  162. package/lib/runtime/file-review.js +44 -7
  163. package/lib/runtime/file-review.js.map +1 -1
  164. package/lib/runtime/herdr-agent.d.ts +4 -13
  165. package/lib/runtime/herdr-agent.js +19 -63
  166. package/lib/runtime/herdr-agent.js.map +1 -1
  167. package/lib/runtime/plain-tui.d.ts +2 -0
  168. package/lib/runtime/plain-tui.js +7 -1
  169. package/lib/runtime/plain-tui.js.map +1 -1
  170. package/lib/runtime/plugin-settings.d.ts +25 -2
  171. package/lib/runtime/plugin-settings.js +329 -31
  172. package/lib/runtime/plugin-settings.js.map +1 -1
  173. package/lib/runtime/provider-local.d.ts +81 -24
  174. package/lib/runtime/provider-local.js +1807 -608
  175. package/lib/runtime/provider-local.js.map +1 -1
  176. package/lib/runtime/runner.js +52 -12
  177. package/lib/runtime/runner.js.map +1 -1
  178. package/lib/runtime/session-archive.d.ts +6 -0
  179. package/lib/runtime/session-archive.js +129 -3
  180. package/lib/runtime/session-archive.js.map +1 -1
  181. package/lib/runtime/session-runtime.js +1 -1
  182. package/lib/runtime/session-runtime.js.map +1 -1
  183. package/lib/runtime/settings-preferences.d.ts +5 -0
  184. package/lib/runtime/settings-preferences.js +34 -0
  185. package/lib/runtime/settings-preferences.js.map +1 -0
  186. package/lib/runtime/startup-notices.js +2 -0
  187. package/lib/runtime/startup-notices.js.map +1 -1
  188. package/lib/runtime/terminal-modes.d.ts +47 -0
  189. package/lib/runtime/terminal-modes.js +86 -0
  190. package/lib/runtime/terminal-modes.js.map +1 -0
  191. package/lib/runtime/terminal-notifications.d.ts +3 -5
  192. package/lib/runtime/terminal-notifications.js +4 -8
  193. package/lib/runtime/terminal-notifications.js.map +1 -1
  194. package/lib/runtime/terminal-probe.d.ts +36 -0
  195. package/lib/runtime/terminal-probe.js +37 -0
  196. package/lib/runtime/terminal-probe.js.map +1 -0
  197. package/lib/runtime/terminal-session.d.ts +57 -0
  198. package/lib/runtime/terminal-session.js +164 -0
  199. package/lib/runtime/terminal-session.js.map +1 -0
  200. package/lib/runtime/terminal-state.d.ts +29 -0
  201. package/lib/runtime/terminal-state.js +62 -0
  202. package/lib/runtime/terminal-state.js.map +1 -0
  203. package/lib/runtime/tool-presentation.js +50 -14
  204. package/lib/runtime/tool-presentation.js.map +1 -1
  205. package/lib/session/external-agents.d.ts +5 -0
  206. package/lib/session/external-agents.js +27 -0
  207. package/lib/session/external-agents.js.map +1 -0
  208. package/lib/session/feature-toggles.d.ts +1 -1
  209. package/lib/session/feature-toggles.js +1 -1
  210. package/lib/session/interaction-adapter.d.ts +1 -6
  211. package/lib/session/interaction-adapter.js +95 -18
  212. package/lib/session/interaction-adapter.js.map +1 -1
  213. package/lib/session/message-queue.js +1 -1
  214. package/lib/session/message-queue.js.map +1 -1
  215. package/lib/session/model-readiness.d.ts +16 -0
  216. package/lib/session/model-readiness.js +43 -0
  217. package/lib/session/model-readiness.js.map +1 -0
  218. package/lib/session/package-metadata.d.ts +8 -0
  219. package/lib/session/package-metadata.js +8 -0
  220. package/lib/session/package-metadata.js.map +1 -1
  221. package/lib/session/project-context.d.ts +5 -0
  222. package/lib/session/project-context.js +36 -0
  223. package/lib/session/project-context.js.map +1 -1
  224. package/lib/session/session-controller.d.ts +10 -13
  225. package/lib/session/session-controller.js +183 -98
  226. package/lib/session/session-controller.js.map +1 -1
  227. package/lib/session/session-library.js +2 -0
  228. package/lib/session/session-library.js.map +1 -1
  229. package/lib/session/session-tree.d.ts +3 -0
  230. package/lib/session/session-tree.js +3 -1
  231. package/lib/session/session-tree.js.map +1 -1
  232. package/lib/session/subagent-roster.d.ts +2 -1
  233. package/lib/session/subagent-roster.js +24 -3
  234. package/lib/session/subagent-roster.js.map +1 -1
  235. package/lib/session/subagent-tracker.d.ts +6 -2
  236. package/lib/session/subagent-tracker.js +49 -2
  237. package/lib/session/subagent-tracker.js.map +1 -1
  238. package/lib/session/tui-settings.d.ts +47 -13
  239. package/lib/session/tui-settings.js +76 -34
  240. package/lib/session/tui-settings.js.map +1 -1
  241. package/lib/session/working-directory.d.ts +4 -0
  242. package/lib/session/working-directory.js +4 -0
  243. package/lib/session/working-directory.js.map +1 -0
  244. package/lib/session/worktrees.d.ts +13 -0
  245. package/lib/session/worktrees.js +49 -0
  246. package/lib/session/worktrees.js.map +1 -0
  247. package/lib/views/approval-rule.d.ts +3 -0
  248. package/lib/views/approval-rule.js +4 -0
  249. package/lib/views/approval-rule.js.map +1 -0
  250. package/lib/views/at-complete.d.ts +8 -0
  251. package/lib/views/at-complete.js +64 -2
  252. package/lib/views/at-complete.js.map +1 -1
  253. package/lib/views/autocomplete.d.ts +22 -2
  254. package/lib/views/autocomplete.js +70 -40
  255. package/lib/views/autocomplete.js.map +1 -1
  256. package/lib/views/copy-targets.d.ts +13 -14
  257. package/lib/views/copy-targets.js +42 -54
  258. package/lib/views/copy-targets.js.map +1 -1
  259. package/lib/views/document-reader.js +18 -5
  260. package/lib/views/document-reader.js.map +1 -1
  261. package/lib/views/event-views.d.ts +1 -1
  262. package/lib/views/event-views.js +60 -6
  263. package/lib/views/event-views.js.map +1 -1
  264. package/lib/views/file-actions.d.ts +12 -0
  265. package/lib/views/file-actions.js +72 -0
  266. package/lib/views/file-actions.js.map +1 -0
  267. package/lib/views/history-search.js +15 -21
  268. package/lib/views/history-search.js.map +1 -1
  269. package/lib/views/hotkeys.d.ts +8 -1
  270. package/lib/views/hotkeys.js +53 -26
  271. package/lib/views/hotkeys.js.map +1 -1
  272. package/lib/views/list-navigation.d.ts +62 -0
  273. package/lib/views/list-navigation.js +73 -0
  274. package/lib/views/list-navigation.js.map +1 -0
  275. package/lib/views/list-view.d.ts +99 -0
  276. package/lib/views/list-view.js +165 -0
  277. package/lib/views/list-view.js.map +1 -0
  278. package/lib/views/prompt-selector.d.ts +34 -4
  279. package/lib/views/prompt-selector.js +270 -134
  280. package/lib/views/prompt-selector.js.map +1 -1
  281. package/lib/views/session-files.d.ts +1 -2
  282. package/lib/views/session-files.js +1 -11
  283. package/lib/views/session-files.js.map +1 -1
  284. package/lib/views/settings-list.d.ts +51 -10
  285. package/lib/views/settings-list.js +267 -315
  286. package/lib/views/settings-list.js.map +1 -1
  287. package/lib/views/trajectory-timeline.d.ts +72 -0
  288. package/lib/views/trajectory-timeline.js +133 -0
  289. package/lib/views/trajectory-timeline.js.map +1 -0
  290. package/lib/views/trajectory.d.ts +15 -4
  291. package/lib/views/trajectory.js +557 -123
  292. package/lib/views/trajectory.js.map +1 -1
  293. package/lib/views/transcript-export.js +9 -0
  294. package/lib/views/transcript-export.js.map +1 -1
  295. package/lib/views/transcript-render.d.ts +97 -25
  296. package/lib/views/transcript-render.js +542 -170
  297. package/lib/views/transcript-render.js.map +1 -1
  298. package/lib/views/transcript-search.d.ts +39 -6
  299. package/lib/views/transcript-search.js +46 -11
  300. package/lib/views/transcript-search.js.map +1 -1
  301. package/lib/views/transcript-types.d.ts +63 -5
  302. package/lib/views/transcript-types.js +23 -11
  303. package/lib/views/transcript-types.js.map +1 -1
  304. package/package.json +95 -65
  305. package/lib/commands/loop.d.ts +0 -52
  306. package/lib/commands/loop.js +0 -307
  307. package/lib/commands/loop.js.map +0 -1
  308. package/lib/commands/steer.d.ts +0 -5
  309. package/lib/commands/steer.js +0 -24
  310. package/lib/commands/steer.js.map +0 -1
  311. package/lib/runtime/terminal-reader.d.ts +0 -11
  312. package/lib/runtime/terminal-reader.js +0 -87
  313. package/lib/runtime/terminal-reader.js.map +0 -1
  314. package/lib/views/agent-hub.d.ts +0 -32
  315. package/lib/views/agent-hub.js +0 -206
  316. package/lib/views/agent-hub.js.map +0 -1
  317. package/lib/views/copy-selector.d.ts +0 -51
  318. package/lib/views/copy-selector.js +0 -139
  319. package/lib/views/copy-selector.js.map +0 -1
@@ -6,30 +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
- import { workspaceBlockLines } from "./workspace-changes.js";
21
+ import { workspaceBlockLines, WORKSPACE_COLLAPSED_LINES } from "./workspace-changes.js";
22
+ import { blockFileAction } from "./file-actions.js";
23
23
  import { renderTool, toolArgSubject } from "../chrome/tool-renderers.js";
24
24
  import { toolArgsObject } from "../chrome/tool-args.js";
25
- import { renderToolsPanel } from "../chrome/tools-list.js";
26
- 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";
27
27
  import { renderPathMentionRows } from "../chrome/path-mentions.js";
28
- 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 };
29
33
  import { contentToText, prettyArgs, reasoningKey, } from "./transcript-types.js";
30
34
  function isBlockPending(block) {
31
35
  return (block.kind === 'assistant' && block.streaming) || (block.kind === 'tool' && block.status === 'running');
32
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
+ }
33
45
  function exclusiveDiffs(presentation) {
34
46
  if (presentation?.result?.card === 'diff')
35
47
  return presentation.result.diffs;
@@ -107,7 +119,9 @@ function assistantMarkdown(source, theme, width, style) {
107
119
  */
108
120
  function userBubble(text, theme, width) {
109
121
  const gutter = ' ';
110
- 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);
111
125
  const rows = ['', ...wrapped, ''];
112
126
  // The padding rows share the prompt's tint, so the prompt is one slab that
113
127
  // opens the turn. Unpainted, they were two blank rows of their own on top of
@@ -116,7 +130,8 @@ function userBubble(text, theme, width) {
116
130
  return rows.map((row, index) => {
117
131
  if (row === '')
118
132
  return paintSurface(theme, 'userMessageBg', 'userMessageText', padToWidth('', width));
119
- const content = padToWidth((index === 1 ? gutter : '') + row, width);
133
+ const promptMark = index === 1 && theme.colors ? '\x1b]133;A\x07' : '';
134
+ const content = padToWidth((index === 1 ? promptMark + gutter : '') + row, width);
120
135
  return paintSurface(theme, 'userMessageBg', 'userMessageText', content);
121
136
  });
122
137
  }
@@ -275,7 +290,7 @@ function toolRowSubject(block, presentation, label) {
275
290
  return firstLineOf(verb !== '' && title.startsWith(verb + ' ') ? title.slice(verb.length + 1) : title);
276
291
  }
277
292
  /** 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') {
293
+ function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt+O', surface = 'toolSuccessBg') {
279
294
  const full = mode !== 'preview';
280
295
  const presentation = renderTool({
281
296
  name: block.name, arguments: prettyArgs(block.args), output: block.output,
@@ -301,8 +316,15 @@ function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt
301
316
  : call?.card === 'generic' || call?.card === 'diff' ? call.title
302
317
  : !read && presentation.title !== undefined && presentation.title !== block.name ? presentation.title
303
318
  : `${block.name}${subject === '' ? '' : ` ${subject}`}`;
304
- const head = theme.bold(theme.fg(block.status === 'error' ? 'error' : 'toolTitle', title));
305
- 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);
306
328
  let body = [];
307
329
  if (mode === 'details') {
308
330
  let input = block.args;
@@ -311,20 +333,26 @@ function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt
311
333
  }
312
334
  catch { /* Partial arguments retain their received text. */ }
313
335
  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);
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));
317
343
  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),
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),
321
349
  ...outputRows,
322
350
  ];
323
351
  // Keep the tool's rich presentation, but expose received text it replaced.
324
352
  if (block.output !== '' && (diffs !== undefined || block.output !== output.join('\n'))) {
325
- body.push('', ...wrapText(theme.fg('dim', 'Raw result'), room));
353
+ body.push('', ...wrapTextStable(theme.fg('dim', 'Raw result'), room));
326
354
  for (const line of block.output.split('\n')) {
327
- 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));
328
356
  }
329
357
  }
330
358
  }
@@ -332,13 +360,21 @@ function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt
332
360
  body = wrapPaintedDiffRows(alignFileDiffs(diffs), theme, room);
333
361
  }
334
362
  else if (full || !read || block.status === 'error') {
335
- 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);
336
371
  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))];
372
+ ? highlightedLines(presentation.input, language ?? 'text', theme, undefined, room)
373
+ : [];
374
+ body = [...input, ...output.flatMap(line => wrapTextStable(line, room))];
339
375
  // Writes carry their content in arguments before the result arrives.
340
376
  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));
377
+ body = highlightedLines(args['content'].split('\n'), toolPayloadLanguage('write', subject) ?? 'text', theme, undefined, room);
342
378
  }
343
379
  }
344
380
  while (body.length > 0 && stripAnsi(body[body.length - 1]).trim() === '')
@@ -351,12 +387,12 @@ function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt
351
387
  const shortcut = detailsKey === 'Disabled' ? '' : ` · ${detailsKey}: Expand`;
352
388
  const hint = theme.fg('dim', `… ${omitted} ${shell ? 'earlier' : 'more'} lines${shortcut}`);
353
389
  if (shell)
354
- body.unshift(...wrapText(hint, room));
390
+ body.unshift(...wrapTextStable(hint, room));
355
391
  else
356
- body.push(...wrapText(hint, room));
392
+ body.push(...wrapTextStable(hint, room));
357
393
  }
358
394
  if (!full && read && block.status !== 'error' && block.output !== '' && detailsKey !== 'Disabled') {
359
- body.push(...wrapText(theme.fg('dim', `${detailsKey}: Expand result`), room));
395
+ body.push(...wrapTextStable(theme.fg('dim', `${detailsKey}: Expand result`), room));
360
396
  }
361
397
  if (full && block.status === 'error' && call?.card === 'diff')
362
398
  rows.push('', ...wrapPaintedDiffRows(alignFileDiffs(call.diffs), theme, room));
@@ -366,10 +402,15 @@ function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt
366
402
  rows.push(row);
367
403
  }
368
404
  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';
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;
373
414
  return ['', ...rows, ''].map(row => paintSurface(theme, bg, 'toolOutput', padToWidth(' '.repeat(padding + nest) + row, width)));
374
415
  }
375
416
  function firstLineOf(text) {
@@ -380,6 +421,18 @@ function firstLineOf(text) {
380
421
  }
381
422
  return '';
382
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
+ }
383
436
  /** Thinking uses the same quiet Markdown style throughout the turn. */
384
437
  function openReasoningLines(block, theme, width, mathMode = 'auto') {
385
438
  return assistantMarkdown(block.reasoning, theme, width, { color: 'thinkingText', italic: true, mathMode });
@@ -400,27 +453,103 @@ function groupMembers(group) {
400
453
  * last row and its words are the answer painted below the run, so the run
401
454
  * paints the thought and nothing else.
402
455
  */
403
- 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) {
404
459
  const { blocks, options, theme, width } = context;
405
460
  const block = blocks[index];
406
461
  // Search must reveal a match even when it falls outside a tool's normal preview.
407
462
  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');
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');
409
467
  const cached = groupMemberCache.get(block);
410
468
  let lines;
411
469
  if (cached?.signature === signature)
412
470
  lines = cached.lines;
413
471
  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' })
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
+ })
417
483
  : block.kind === 'notice' ? blockLines(block, theme, width) : [];
418
484
  groupMemberCache.set(block, { signature, lines });
419
485
  }
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];
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));
424
553
  }
425
554
  /** A notice that reports on the run's work, as one of its rows. */
426
555
  function processNoticeLine(block, theme, width, indent) {
@@ -439,25 +568,21 @@ const groupMemberCache = new WeakMap();
439
568
  function processGroupBody(group, context) {
440
569
  const lines = [];
441
570
  const offsets = new Map();
571
+ const spans = [];
442
572
  for (const index of groupMembers(group)) {
443
- 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;
444
577
  if (rows.length > 0 && lines.length > 0)
445
578
  lines.push('');
446
579
  offsets.set(index, lines.length);
580
+ if (context.blocks[index]?.kind === 'tool')
581
+ spans.push({ index, start: lines.length, end: lines.length + rows.length });
447
582
  for (const row of rows)
448
583
  lines.push(row);
449
584
  }
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`;
585
+ return { lines, offsets, spans };
461
586
  }
462
587
  /** A completed turn's duration and any failures remain visible when folded. */
463
588
  function processGroupHeader(group, theme, width, elapsed, expandKey = 'Ctrl+O') {
@@ -475,13 +600,16 @@ function processGroupHeader(group, theme, width, elapsed, expandKey = 'Ctrl+O')
475
600
  * to direct callers; turn inspection uses previews. omitReasoning excludes
476
601
  * thinking already drawn by a run.
477
602
  */
478
- 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' } = {}) {
479
604
  if (block.kind === 'user')
480
605
  return userBubble(block.text, theme, width);
481
606
  if (block.kind === 'assistant') {
482
607
  const lines = [];
483
608
  if (block.reasoning !== '' && !omitReasoning) {
484
- 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)));
485
613
  if (block.text !== '')
486
614
  lines.push('');
487
615
  }
@@ -501,12 +629,30 @@ export function blockLines(block, theme, width, { toolsExpanded = false, omitRea
501
629
  if (block.kind === 'tool') {
502
630
  return toolBlockLines(block, theme, width, toolsExpanded ? 'full' : 'preview', toolDetailsKey);
503
631
  }
504
- if (block.kind === 'toolCatalog')
505
- return renderToolsPanel(block.tools, theme, width, toolsExpanded);
506
632
  if (block.kind === 'workspace')
507
633
  return workspaceBlockLines(block, theme, width, toolsExpanded);
508
- if (block.kind === 'commandOutput')
509
- 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);
510
656
  if (block.boundary === true)
511
657
  return [renderTranscriptBoundary(block.text, theme, width)];
512
658
  if (block.process !== undefined && block.framed !== true)
@@ -515,11 +661,11 @@ export function blockLines(block, theme, width, { toolsExpanded = false, omitRea
515
661
  const prefix = ' ';
516
662
  const continuation = ' '.repeat(visibleWidth(prefix));
517
663
  const tone = block.level === 'error' ? 'error' : block.level === 'warning' ? 'warning' : 'dim';
518
- 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));
519
665
  }
520
666
  const state = block.level === 'error' ? 'error' : block.level === 'warning' ? 'warning' : 'info';
521
667
  const paint = (text) => theme.fg(block.level === 'error' ? 'error' : block.level === 'warning' ? 'warning' : 'dim', text);
522
- const wrapped = wrapText(block.text, Math.max(1, width - 4));
668
+ const wrapped = wrapTextStable(block.text, Math.max(1, width - 4));
523
669
  const title = paint(wrapped[0] ?? '');
524
670
  return renderFramedBlock({
525
671
  header: title,
@@ -563,6 +709,55 @@ function renderTranscriptBoundary(label, theme, width) {
563
709
  const left = Math.floor(room / 2);
564
710
  return rule.repeat(left) + ' ' + labelText + ' ' + rule.repeat(room - left);
565
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
+ }
566
761
  /** Plain text of one block, used by transcript search and match painting. */
567
762
  export function blockSearchText(block) {
568
763
  if (block.kind === 'assistant') {
@@ -570,14 +765,13 @@ export function blockSearchText(block) {
570
765
  }
571
766
  if (block.kind === 'tool')
572
767
  return `${block.args}\n${block.output}${block.job === undefined ? '' : `\n${block.job.id} ${block.job.status} ${block.job.detail ?? ''}`}`;
573
- if (block.kind === 'toolCatalog') {
574
- return block.tools.map(tool => `${tool.name} ${tool.description}`).join('\n');
575
- }
576
768
  if (block.kind === 'workspace') {
577
769
  // Only the served file paths are searchable; a turn whose summary the
578
770
  // host no longer holds contributes no path text.
579
771
  return block.summary?.files.map(file => file.display).join('\n') ?? `turn ${block.turn}`;
580
772
  }
773
+ if (block.kind === 'shell')
774
+ return `${block.command}\n${block.output}`;
581
775
  return block.text;
582
776
  }
583
777
  function fitFrame(lines, width, stablePrefix = 0) {
@@ -601,35 +795,53 @@ function fitFrame(lines, width, stablePrefix = 0) {
601
795
  const transcriptBodyCache = new WeakMap();
602
796
  /** Settled immutable blocks keep their expensive Markdown/tool layout. */
603
797
  const blockLinesCache = new WeakMap();
604
- function cachedBlockLines(block, options, theme, themeName, trueColor, expanded, omitReasoning = false) {
798
+ function cachedBlockLines(block, options, theme, themeName, depth, expanded, omitReasoning = false) {
605
799
  const cached = blockLinesCache.get(block);
606
800
  if (cached !== undefined
607
801
  && cached.width === options.width
608
802
  && cached.colors === options.colors
609
- && cached.trueColor === trueColor
803
+ && cached.depth === depth
610
804
  && cached.themeName === themeName
805
+ && cached.terminalKey === terminalKey(options)
611
806
  && cached.expanded === expanded
612
807
  && cached.omitReasoning === omitReasoning
808
+ && cached.hideThinking === (options.hideThinking === true)
809
+ && cached.thinkingOpen === (block.kind === 'assistant' && options.expandedReasoning?.has(reasoningKey(block)) === true)
613
810
  && cached.mathMode === options.mathMode
614
811
  && cached.mermaidMode === options.mermaidMode
615
812
  && cached.toolDetailsKey === options.toolDetailsKey
616
- && 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))
617
815
  return cached.lines;
618
816
  const toolDetails = block.kind === 'tool' && options.expandedTools?.has(block.callId) === true;
619
817
  const lines = block.kind === 'tool' && toolDetails
620
818
  ? 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' });
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
+ });
622
830
  blockLinesCache.set(block, {
623
831
  mathMode: options.mathMode,
624
832
  mermaidMode: options.mermaidMode,
625
833
  toolDetailsKey: options.toolDetailsKey,
626
834
  toolDetails,
835
+ expandedOutput: options.expandedOutputs?.has(block) === true,
627
836
  width: options.width,
628
837
  colors: options.colors,
629
- trueColor,
838
+ depth,
630
839
  themeName,
840
+ terminalKey: terminalKey(options),
631
841
  expanded,
632
842
  omitReasoning,
843
+ hideThinking: options.hideThinking === true,
844
+ thinkingOpen: block.kind === 'assistant' && options.expandedReasoning?.has(reasoningKey(block)) === true,
633
845
  lines,
634
846
  });
635
847
  return lines;
@@ -653,7 +865,7 @@ const PROCESS_LABELS = [
653
865
  { match: /^(web_search)$/, label: 'Search web' },
654
866
  { match: /^(web_fetch)$/, label: 'Fetch page' },
655
867
  {
656
- match: /^(subagent|subagent_fork|subagent_isolated|agent|ralph|workflow_run)$/, label: 'Run subagent',
868
+ match: /^(subagent|subagent_fork|subagent_isolated|subagent_codex|subagent_claude_code|agent|ralph|workflow_run)$/, label: 'Run subagent',
657
869
  },
658
870
  {
659
871
  match: /^(session_search|session_trace|session_event_read|session_event_search|session_event_trace)$/,
@@ -861,29 +1073,46 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
861
1073
  const toolsExpanded = options.toolsExpanded === true;
862
1074
  const expandedTools = [...(options.expandedTools ?? [])].sort().join('\0');
863
1075
  const expandedReasoning = [...(options.expandedReasoning ?? [])].sort().join('\0');
1076
+ const hideThinking = options.hideThinking === true;
864
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');
865
1081
  const themeName = options.themeName ?? 'dark';
866
- const trueColor = options.trueColor === true;
1082
+ const depth = colorDepthOf(options);
867
1083
  const animatedSpinnerFrame = state.blocks.some(block => block.kind === 'tool' && block.status === 'running')
868
1084
  ? spinnerFrame
869
1085
  : -1;
870
1086
  const search = options.transcriptSearch;
871
- const searchKey = search === undefined
872
- ? ''
873
- : `${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);
874
1100
  const cached = transcriptBodyCache.get(state.blocks);
875
1101
  if (cached !== undefined
876
1102
  && cached.width === options.width
877
1103
  && cached.colors === options.colors
878
- && cached.trueColor === trueColor
1104
+ && cached.depth === depth
879
1105
  && cached.themeName === themeName
1106
+ && cached.terminalKey === terminalKey(options)
880
1107
  && cached.spinnerFrame === animatedSpinnerFrame
881
1108
  && cached.mathMode === options.mathMode
882
1109
  && cached.mermaidMode === options.mermaidMode
883
1110
  && cached.toolsExpanded === toolsExpanded
884
1111
  && cached.expandedTools === expandedTools
885
1112
  && cached.expandedReasoning === expandedReasoning
1113
+ && cached.hideThinking === hideThinking
886
1114
  && cached.openedGroups === openedGroups
1115
+ && cached.expandedOutputs === expandedOutputs
887
1116
  && cached.toolDetailsKey === options.toolDetailsKey
888
1117
  && cached.turnSpans === state.turnSpans
889
1118
  && cached.turnDetailsKey === options.turnDetailsKey
@@ -892,15 +1121,33 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
892
1121
  && cached.focusBlock === options.focusBlock) {
893
1122
  return {
894
1123
  lines: cached.lines,
1124
+ fileRegions: cached.fileRegions,
895
1125
  blockStarts: cached.blockStarts,
896
1126
  blockDrawStarts: cached.blockDrawStarts,
897
1127
  foldShape: cached.foldShape,
898
1128
  foldMarks: cached.foldMarks,
899
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,
900
1133
  };
901
1134
  }
902
- const matches = new Set(search?.matches ?? []);
1135
+ const matches = new Set(occurrences.map(match => match.block));
903
1136
  const lines = [];
1137
+ const fileRegions = [];
1138
+ const filesAt = (index, start, length, expanded = false) => {
1139
+ const block = state.blocks[index];
1140
+ if (length === 0 || blockFileAction(block) === undefined)
1141
+ return;
1142
+ if (block.kind === 'workspace') {
1143
+ fileRegions.push({ block: index, start, end: start + 1 });
1144
+ const visible = Math.min(block.summary.files.length, expanded ? length - 1 : WORKSPACE_COLLAPSED_LINES);
1145
+ for (let file = 0; file < visible; file += 1)
1146
+ fileRegions.push({ block: index, start: start + file + 1, end: start + file + 2, file });
1147
+ }
1148
+ else
1149
+ fileRegions.push({ block: index, start, end: start + length });
1150
+ };
904
1151
  const blockStarts = [];
905
1152
  // Where each block is drawn, apart from the offsets above: a search can aim a
906
1153
  // block's offset at the thought it also carries, but its drawn span does not
@@ -960,10 +1207,16 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
960
1207
  const foldMarks = [];
961
1208
  if (toolsExpanded)
962
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 });
963
1214
  let liveFrom;
964
1215
  const context = {
965
- blocks: state.blocks, theme, themeName, trueColor, width: options.width,
966
- 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 },
967
1220
  };
968
1221
  const compactProcessRow = (block) => {
969
1222
  if (block?.kind === 'notice')
@@ -993,21 +1246,34 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
993
1246
  lines.push('');
994
1247
  const headerRow = lines.length;
995
1248
  const opened = openGroups.has(groupIndex);
996
- 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';
997
1261
  foldMarks.push({ key: `run:${group.key}:${shape}`, row: headerRow });
998
1262
  if (group.live)
999
1263
  liveFrom ??= headerRow;
1000
1264
  // A live run has no header: it becomes a group when its turn ends.
1001
- if (!group.live && !opened) {
1265
+ if (!group.live && !shown) {
1002
1266
  lines.push(...processGroupHeader(group, theme, options.width, elapsedOf(groupIndex), options.turnDetailsKey));
1003
1267
  }
1004
1268
  const bodyRow = lines.length;
1005
1269
  let offsets = new Map();
1006
- if (group.live || opened) {
1270
+ if (group.live || shown) {
1007
1271
  const body = processGroupBody(group, context);
1008
1272
  for (const line of body.lines)
1009
1273
  lines.push(line);
1010
1274
  offsets = body.offsets;
1275
+ for (const span of body.spans)
1276
+ filesAt(span.index, bodyRow + span.start, span.end - span.start);
1011
1277
  }
1012
1278
  else {
1013
1279
  for (let at = group.start; at < (group.answer ?? group.until); at += 1) {
@@ -1016,7 +1282,9 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1016
1282
  && !(member.kind === 'notice' && member.level === 'error'))
1017
1283
  continue;
1018
1284
  offsets.set(at, lines.length - bodyRow);
1019
- lines.push(...(member.kind === 'tool' ? toolRowLine(member, theme, options.width, spinnerFrame) : groupMemberLines(at, context)));
1285
+ const rows = member.kind === 'tool' ? toolRowLine(member, theme, options.width, spinnerFrame) : groupMemberLines(at, context);
1286
+ filesAt(at, lines.length, rows.length);
1287
+ lines.push(...rows);
1020
1288
  }
1021
1289
  }
1022
1290
  // The turn's answer, painted as a row of its own between the run's work
@@ -1045,10 +1313,8 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1045
1313
  blockDrawStarts[group.answer] = lines.length;
1046
1314
  offsets.set(group.answer, lines.length - bodyRow);
1047
1315
  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))
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))
1052
1318
  lines.push(line);
1053
1319
  if (thoughtHit && thoughtRow !== undefined)
1054
1320
  blockStarts[group.answer] = bodyRow + thoughtRow;
@@ -1074,7 +1340,8 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1074
1340
  offsets.set(at, lines.length - bodyRow);
1075
1341
  const rows = !group.live && !opened && trailing?.kind === 'tool'
1076
1342
  ? toolRowLine(trailing, theme, options.width, spinnerFrame)
1077
- : groupMemberLines(at, context);
1343
+ : groupMemberLines(at, context, false, 0, group.live || opened);
1344
+ filesAt(at, lines.length, rows.length);
1078
1345
  for (const line of rows)
1079
1346
  lines.push(line);
1080
1347
  }
@@ -1135,10 +1402,9 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1135
1402
  liveFrom ??= lines.length;
1136
1403
  const expanded = (block.kind === 'workspace' && groups.some((group, at) => group.turn === block.turn && openGroups.has(at))) || matches.has(index) || toolsExpanded
1137
1404
  || (block.kind === 'tool' && options.expandedTools?.has(block.callId) === true);
1138
- const rendered = cachedBlockLines(block, options, theme, themeName, trueColor, expanded, absorbed.has(index));
1139
- lines.push(...(search !== undefined && matches.has(index)
1140
- ? rendered.map(line => blockMatchesQuery(stripAnsi(line), search.query) ? theme.inverse(line) : line)
1141
- : rendered));
1405
+ const rendered = cachedBlockLines(block, options, theme, themeName, depth, expanded, absorbed.has(index));
1406
+ filesAt(index, lines.length, rendered.length, expanded);
1407
+ lines.push(...paintSearch(rendered, index, occurrences, theme));
1142
1408
  blockDrawStarts[index] = blockStarts[index];
1143
1409
  previous = block;
1144
1410
  }
@@ -1146,6 +1412,7 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1146
1412
  // this; opening, folding, or a run settling at turn end do.
1147
1413
  const foldShape = foldMarks.map(mark => mark.key).join('\u0001');
1148
1414
  transcriptBodyCache.set(state.blocks, {
1415
+ fileRegions,
1149
1416
  mathMode: options.mathMode,
1150
1417
  mermaidMode: options.mermaidMode,
1151
1418
  toolDetailsKey: options.toolDetailsKey,
@@ -1153,13 +1420,16 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1153
1420
  blockDrawStarts,
1154
1421
  width: options.width,
1155
1422
  colors: options.colors,
1156
- trueColor,
1423
+ depth,
1157
1424
  themeName,
1425
+ terminalKey: terminalKey(options),
1158
1426
  spinnerFrame: animatedSpinnerFrame,
1159
1427
  toolsExpanded,
1160
1428
  expandedTools,
1161
1429
  expandedReasoning,
1430
+ hideThinking,
1162
1431
  openedGroups,
1432
+ expandedOutputs,
1163
1433
  searchKey,
1164
1434
  turnSpans: state.turnSpans,
1165
1435
  status: state.status,
@@ -1170,11 +1440,9 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1170
1440
  lines,
1171
1441
  blockStarts,
1172
1442
  });
1173
- return { lines, blockStarts, blockDrawStarts, foldShape, foldMarks, liveFrom };
1443
+ return { lines, blockStarts, blockDrawStarts, fileRegions, foldShape, foldMarks, liveFrom, occurrences };
1174
1444
  }
1175
1445
  function commandSurfaceName(block) {
1176
- if (block?.kind === 'toolCatalog')
1177
- return 'tools';
1178
1446
  if (block?.kind === 'commandOutput')
1179
1447
  return block.command;
1180
1448
  return undefined;
@@ -1353,6 +1621,39 @@ function todoPreviewStart(todos) {
1353
1621
  const pending = todos.findIndex(todo => todo.status === 'pending');
1354
1622
  return pending >= 0 ? pending : Math.max(0, todos.length - TODO_PREVIEW);
1355
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
+ }
1356
1657
  /** Compact, unframed Todo tree placed above queued messages. */
1357
1658
  export function renderTodos(todos, theme, width) {
1358
1659
  if (todos.length === 0 || width <= 0)
@@ -1382,7 +1683,8 @@ export function renderTodos(todos, theme, width) {
1382
1683
  })];
1383
1684
  return lines.map(line => truncateToWidth(line, width));
1384
1685
  }
1385
- function subagentPreviewStart(agents) {
1686
+ /** First row the bar shows; the provider starts its selection here. */
1687
+ export function subagentPreviewStart(agents) {
1386
1688
  if (agents.length <= SUBAGENT_PREVIEW)
1387
1689
  return 0;
1388
1690
  const active = agents.findIndex(agent => agent.phase === 'running' || agent.phase === 'starting');
@@ -1406,12 +1708,24 @@ function subagentPhaseGlyph(phase, theme, spinnerFrame) {
1406
1708
  const SUBAGENT_PHASE_LABELS = {
1407
1709
  starting: 'Starting', running: 'Running', waiting: 'Waiting', completed: 'Done', error: 'Failed',
1408
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
+ }
1409
1723
  function subagentRowText(agent) {
1410
1724
  const indent = agent.depth > 1 ? ' '.repeat(agent.depth - 1) : '';
1411
1725
  return `${indent}${agent.label} · ${SUBAGENT_PHASE_LABELS[agent.phase]}`;
1412
1726
  }
1413
1727
  /** Compact, unframed descendant-subagent tree placed above Todos. */
1414
- export function renderSubagents(roster, theme, width, spinnerFrame = 0, inspectedId, launcherFocused = false) {
1728
+ export function renderSubagents(roster, theme, width, spinnerFrame = 0, inspectedId, selected) {
1415
1729
  const agents = roster?.agents ?? [];
1416
1730
  if (agents.length === 0 || width <= 0)
1417
1731
  return [];
@@ -1425,10 +1739,16 @@ export function renderSubagents(roster, theme, width, spinnerFrame = 0, inspecte
1425
1739
  done === 0 ? undefined : `${done} done`,
1426
1740
  failed === 0 ? undefined : `${failed} failed`,
1427
1741
  ].filter((part) => part !== undefined);
1742
+ const pointing = selected !== undefined;
1428
1743
  const headerBody = theme.bold(theme.fg('accent', 'Agents'))
1429
1744
  + (counts.length === 0 ? '' : theme.fg('dim', ` · ${counts.join(' · ')}`))
1430
- + theme.fg('dim', launcherFocused ? ' · Enter open · Esc return' : ' · ↓ select · Alt+A open');
1431
- 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}`}`));
1432
1752
  const start = subagentPreviewStart(agents);
1433
1753
  const end = Math.min(agents.length, start + SUBAGENT_PREVIEW);
1434
1754
  const rows = [
@@ -1436,11 +1756,11 @@ export function renderSubagents(roster, theme, width, spinnerFrame = 0, inspecte
1436
1756
  ...agents.slice(start, end),
1437
1757
  ...(end === agents.length ? [] : [`… ${agents.length - end} more`]),
1438
1758
  ];
1439
- const lines = [header, ...rows.map((row, index) => {
1759
+ const lines = [header, ...workflows, ...rows.map((row, index) => {
1440
1760
  const branch = ' ' + theme.fg('dim', index === rows.length - 1 ? '└─' : '├─') + ' ';
1441
1761
  if (typeof row === 'string')
1442
1762
  return branch + theme.fg('dim', row);
1443
- const selected = inspectedId === row.id;
1763
+ const current = inspectedId === row.id;
1444
1764
  const paint = row.phase === 'completed'
1445
1765
  ? (text) => theme.fg('dim', theme.strikethrough(text))
1446
1766
  : row.phase === 'error'
@@ -1448,9 +1768,10 @@ export function renderSubagents(roster, theme, width, spinnerFrame = 0, inspecte
1448
1768
  : row.phase === 'waiting'
1449
1769
  ? (text) => theme.fg('dim', text)
1450
1770
  : (text) => text;
1451
- const marker = selected ? theme.fg('accent', SYMBOL.cursor) + ' ' : '';
1771
+ const pointed = index === selected;
1772
+ const marker = current || pointed ? theme.fg('accent', SYMBOL.cursor) + ' ' : '';
1452
1773
  const body = marker + subagentPhaseGlyph(row.phase, theme, spinnerFrame) + ' ' + paint(subagentRowText(row));
1453
- return branch + (selected ? theme.bold(body) : body);
1774
+ return branch + (current || pointed ? theme.bold(body) : body);
1454
1775
  })];
1455
1776
  return lines.map(line => truncateToWidth(line, width));
1456
1777
  }
@@ -1540,27 +1861,32 @@ export function renderQueuedSubmissions(submissions, theme, width, inbox = []) {
1540
1861
  const paintedLeft = truncateToWidth(left, width - actionWidth - 2);
1541
1862
  return paintedLeft + ' ' + action;
1542
1863
  };
1864
+ const editSingleHint = width >= 72 ? '↑/Alt+Up edit · Alt+Q queue' : '↑ edit';
1865
+ const editMultiHint = width >= 72 ? '↑/Alt+Up edit latest · Alt+Q queue' : '↑ edit latest';
1543
1866
  if (labels.length === 1) {
1544
- return [alignAction(rail + queueLabel + theme.fg('dim', ' · ') + theme.fg('dim', 'next turn · ') + theme.fg('text', labels[0] ?? ''), theme.fg('dim', '↑ edit'))];
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))];
1545
1871
  }
1546
- const summary = ` · ${labels.length} · next turn${hidden === 0 ? '' : ` · ${hidden} earlier`}`;
1872
+ const summary = ` · ${labels.length} · follow-up${hidden === 0 ? '' : ` · ${hidden} earlier`}`;
1547
1873
  const visibleLabels = labels.slice(start);
1548
1874
  const lines = [
1549
- alignAction(rail + queueLabel + theme.fg('dim', summary), theme.fg('dim', '↑ edit latest'), false),
1875
+ alignAction(rail + queueLabel + theme.fg('dim', summary), theme.fg('dim', editMultiHint), false),
1550
1876
  ...visibleLabels.map((label, index) => rail + theme.fg(index === visibleLabels.length - 1 ? 'accent' : 'dim', String(start + index + 1))
1551
1877
  + ' ' + theme.fg('text', label)),
1552
1878
  ];
1553
1879
  return lines.map(line => truncateToWidth(line, width));
1554
1880
  }
1555
1881
  /**
1556
- * Compose the welcome card, transcript, working row, and rounded editor into
1882
+ * Compose the startup header, transcript, working row, and ruled editor into
1557
1883
  * one frame — the oh-my-pi surface.
1558
1884
  * @param state - transcript state.
1559
1885
  * @param options - terminal geometry and input state.
1560
1886
  * @returns the frame to hand to a renderer.
1561
1887
  */
1562
1888
  export function renderView(state, options) {
1563
- 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);
1564
1890
  const width = options.width;
1565
1891
  const height = options.height;
1566
1892
  const appName = options.appName ?? 'omdsh';
@@ -1568,16 +1894,6 @@ export function renderView(state, options) {
1568
1894
  const pwd = options.pwd ?? '';
1569
1895
  const motion = options.motion ?? 'full';
1570
1896
  const spinnerFrame = motion === 'off' ? -1 : options.spinnerFrame ?? 0;
1571
- if (options.agentHub !== undefined) {
1572
- const hub = renderAgentHub(options.agentHub, theme, width, height);
1573
- return {
1574
- lines: fitFrame(hub.lines, width),
1575
- cursor: hub.cursor,
1576
- cursorVisible: hub.cursorVisible,
1577
- // A full-screen surface drew no transcript body.
1578
- documentRows: null,
1579
- };
1580
- }
1581
1897
  if (options.trajectory !== undefined) {
1582
1898
  const trajectory = renderTrajectory(options.trajectory, theme, width, height);
1583
1899
  return {
@@ -1598,17 +1914,6 @@ export function renderView(state, options) {
1598
1914
  documentRows: null,
1599
1915
  };
1600
1916
  }
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);
1603
- return {
1604
- lines: fitFrame(selector.lines, width),
1605
- ...(selector.document === undefined ? {} : { promptDocument: selector.document }),
1606
- cursor: selector.cursor,
1607
- cursorVisible: true,
1608
- // A full-screen surface drew no transcript body.
1609
- documentRows: null,
1610
- };
1611
- }
1612
1917
  if (options.promptSelector?.request.presentation === 'plan-review' || options.promptSelector?.request.presentation === 'document') {
1613
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);
1614
1919
  return {
@@ -1623,12 +1928,8 @@ export function renderView(state, options) {
1623
1928
  }
1624
1929
  const welcome = renderWelcome({
1625
1930
  width,
1626
- model: options.model,
1627
- ...(options.reasoningEffort === undefined ? {} : { reasoningEffort: options.reasoningEffort }),
1628
1931
  version,
1629
- appName,
1630
- ...(options.recentSessions === undefined ? {} : { recentSessions: options.recentSessions }),
1631
- ...(options.welcomeTips === undefined ? {} : { tips: options.welcomeTips }),
1932
+ appName: options.displayName ?? appName,
1632
1933
  }, theme);
1633
1934
  const transcript = renderTranscriptBody(state, options, theme, spinnerFrame);
1634
1935
  const body = [...welcome];
@@ -1639,22 +1940,37 @@ export function renderView(state, options) {
1639
1940
  transcriptStart = body.length;
1640
1941
  body.push(...transcript.lines);
1641
1942
  }
1642
- const working = state.status === 'running'
1643
- ? 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
1644
1948
  : state.status === 'compacting'
1645
- ? renderWorking(theme, Math.max(0, spinnerFrame), 'Compacting', width, motion)
1646
- : [];
1949
+ ? options.workingLabel ?? 'Compacting'
1950
+ : undefined;
1951
+ const working = [];
1647
1952
  const statusBar = resolveStatusBarConfig(options.statusBar, options.statusPreset);
1953
+ const placeholder = options.inspected === undefined && state.status === 'idle'
1954
+ && options.input === '' && (options.inputImages ?? 0) === 0
1955
+ ? options.modelReady === false
1956
+ ? 'Run /login to connect a model'
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'
1962
+ : null;
1648
1963
  const inlineHint = options.transcriptSearch !== undefined
1649
1964
  ? transcriptSearchHint({
1650
1965
  query: options.transcriptSearch.query,
1651
1966
  editing: options.transcriptSearch.editing,
1652
1967
  focus: options.transcriptSearch.focus,
1653
- }, options.transcriptSearch.matches.length)
1968
+ }, transcript.occurrences.length)
1654
1969
  : options.inspected === undefined
1655
1970
  ? slashInlineHint(options.input, options.inputCursor, options.commands)
1971
+ ?? placeholder
1656
1972
  ?? (state.status === 'running' && options.steerKey !== undefined
1657
- ? `Enter: next turn · ${options.steerKey}: next step` : null)
1973
+ ? `Enter: follow-up · ${options.steerKey}: next step` : null)
1658
1974
  : options.inspected.writable
1659
1975
  ? slashInlineHint(options.input, options.inputCursor, options.commands) ?? 'Enter to steer · Esc to return'
1660
1976
  : 'Read-only · Esc to return';
@@ -1677,29 +1993,52 @@ export function renderView(state, options) {
1677
1993
  inputCursor: options.inputCursor,
1678
1994
  status: ' ' + theme.fg('accent', '🐳') + ' ',
1679
1995
  ...(permissionBadge === '' ? {} : { statusRight: ' ' + permissionBadge + ' ' }),
1680
- border: options.inspected !== undefined && options.inspected.writable !== true
1681
- || state.status === 'idle'
1682
- ? 'border'
1683
- : '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',
1684
2006
  ...(theme.colors && (leadingSlashCommandNameRange(options.input) !== null
1685
2007
  || (options.inputImages !== undefined && options.inputImages !== 0))
1686
2008
  ? { paintInput: (text, start) => paintComposerInputSlice(options.input, text, start, theme) }
1687
2009
  : {}),
1688
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
+ }),
1689
2020
  };
1690
- 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
1691
2033
  ? undefined
1692
- : 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))));
1693
2035
  const settings = promptSelector !== undefined || options.settings === undefined
1694
2036
  ? undefined
1695
2037
  : renderSettings(options.settings, theme, width);
1696
- const copySelector = promptSelector !== undefined || settings !== undefined || options.copySelector === undefined
1697
- ? undefined
1698
- : renderCopySelector(options.copySelector, theme, width);
1699
- const search = promptSelector !== undefined || settings !== undefined || copySelector !== undefined || options.historySearch === undefined
2038
+ const search = promptSelector !== undefined || settings !== undefined || options.historySearch === undefined
1700
2039
  ? undefined
1701
2040
  : renderHistorySearch(options.historySearch, theme, width, Math.max(1, Math.min(HISTORY_SEARCH_MAX_VISIBLE, height - working.length - 8)));
1702
- const editor = promptSelector === undefined && settings === undefined && copySelector === undefined && search === undefined
2041
+ const editor = promptSelector === undefined && settings === undefined && search === undefined
1703
2042
  ? renderEditor(editorOpts, theme)
1704
2043
  : undefined;
1705
2044
  const queuedSubmissions = editor === undefined || options.inspected !== undefined
@@ -1714,6 +2053,9 @@ export function renderView(state, options) {
1714
2053
  ? []
1715
2054
  : renderTurnError(state.turnError, theme, width);
1716
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)));
1717
2059
  const goal = editor === undefined || options.inspected !== undefined || options.sessionControls?.goal === undefined
1718
2060
  ? []
1719
2061
  : renderGoalBar(options.sessionControls.goal, theme, width);
@@ -1724,15 +2066,15 @@ export function renderView(state, options) {
1724
2066
  const inspect = editor === undefined ? [] : renderInspectBanner(options.inspected, theme, width, spinnerFrame);
1725
2067
  const subagents = editor === undefined
1726
2068
  ? []
1727
- : renderSubagents(options.subagents, theme, width, spinnerFrame, options.inspected?.id, options.subagentLauncherFocused);
1728
- 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
1729
2071
  || options.autocomplete === undefined
1730
2072
  ? []
1731
2073
  : renderAutocomplete(options.autocomplete.items, options.autocomplete.selected, theme, width);
1732
- const inputLines = promptSelector?.lines ?? settings?.lines ?? copySelector?.lines ?? search?.lines
2074
+ const inputLines = promptSelector?.lines ?? settings?.lines ?? search?.lines
1733
2075
  ?? (editor === undefined ? [] : editor.lines);
1734
2076
  const spacer = 1;
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;
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;
1736
2078
  const budget = Math.max(0, height - reserved);
1737
2079
  const focusIndex = options.focusBlock === undefined
1738
2080
  ? undefined
@@ -1746,7 +2088,7 @@ export function renderView(state, options) {
1746
2088
  const requestedStart = focusStart === undefined
1747
2089
  ? (options.scrollStart ?? Number.POSITIVE_INFINITY)
1748
2090
  : transcriptStart + focusStart;
1749
- const hasOverlay = promptSelector !== undefined || settings !== undefined || copySelector !== undefined || search !== undefined;
2091
+ const hasOverlay = promptSelector !== undefined || settings !== undefined || search !== undefined;
1750
2092
  const isFollowing = requestedStart === Number.POSITIVE_INFINITY && !hasOverlay;
1751
2093
  // A run still at work reshapes as it goes — its newest rows scroll, it folds
1752
2094
  // when the turn ends — so none of it may freeze into history until it
@@ -1766,9 +2108,29 @@ export function renderView(state, options) {
1766
2108
  if (visible.length > 0)
1767
2109
  lines.push(showJump
1768
2110
  ? ' '.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;
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;
1770
2112
  const fill = Math.max(0, height - lines.length - bottomRows);
1771
- 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);
1772
2134
  lines.push(...working);
1773
2135
  lines.push(...goal);
1774
2136
  lines.push(...questions);
@@ -1789,10 +2151,10 @@ export function renderView(state, options) {
1789
2151
  ? pendingRow
1790
2152
  : Math.min(pendingRow ?? transcript.liveFrom, transcript.liveFrom);
1791
2153
  if (liveRow !== undefined) {
1792
- liveStart = transcriptStart + liveRow;
2154
+ liveStart = transcriptStart + liveRow + topPad;
1793
2155
  }
1794
2156
  else {
1795
- liveStart = lines.length - bottomRows;
2157
+ liveStart = lines.length - bottomRows + topPad;
1796
2158
  }
1797
2159
  // liveStart marks the first row that is still live/mutable. Rows above it
1798
2160
  // are committed. Off-screen live rows are bounded by the renderer: it only
@@ -1802,18 +2164,23 @@ export function renderView(state, options) {
1802
2164
  else {
1803
2165
  liveStart = 0;
1804
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)];
1805
2172
  // Every settled block was already rendered against this width. Mirroring
1806
2173
  // OMP's stable-prefix preparation, only validate the mutable suffix instead
1807
2174
  // of measuring the complete native-scrollback history on every frame.
1808
- const trimmed = fitFrame(lines, width, isFollowing ? liveStart : 0);
1809
- 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 };
1810
2177
  // Which document rows this frame's body came from. `body` already includes the
1811
2178
  // header, so a window's own range is already in document coordinates and must
1812
2179
  // not have the header added again. No window means the whole body was drawn —
1813
2180
  // that is a known range, not a frame that failed to report one, and treating
1814
2181
  // it as "no body" would clear the record on every ordinary follow frame.
1815
2182
  const windowRange = windowed === undefined
1816
- ? (body.length === 0 ? null : { documentStart: 0, documentEnd: body.length, frameStart: 0 })
2183
+ ? (body.length === 0 ? null : { documentStart: 0, documentEnd: body.length + topPad, frameStart: 0 })
1817
2184
  : windowed.documentRows;
1818
2185
  const bodySource = windowRange === undefined
1819
2186
  ? null
@@ -1826,11 +2193,11 @@ export function renderView(state, options) {
1826
2193
  continue;
1827
2194
  const previous = stickyHeaders.at(-1);
1828
2195
  if (previous !== undefined)
1829
- previous.end = transcriptStart + transcript.blockStarts[at];
2196
+ previous.end = transcriptStart + topPad + transcript.blockStarts[at];
1830
2197
  const padding = width > 4 ? 2 : 0;
1831
2198
  const label = truncateToWidth(promptSummary(block), Math.max(1, width - padding * 2));
1832
2199
  stickyHeaders.push({
1833
- start: transcriptStart + (transcript.blockStarts[at + 1] ?? transcript.lines.length),
2200
+ start: transcriptStart + topPad + (transcript.blockStarts[at + 1] ?? transcript.lines.length),
1834
2201
  end: body.length,
1835
2202
  text: paintSurface(theme, 'userMessageBg', 'userMessageText', padToWidth(' '.repeat(padding) + label, width)),
1836
2203
  });
@@ -1839,16 +2206,20 @@ export function renderView(state, options) {
1839
2206
  return {
1840
2207
  lines: trimmed,
1841
2208
  stickyHeaders,
1842
- ...(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 }),
1843
2214
  documentRows: bodySource,
1844
2215
  cursor: {
1845
- row: editorStart + caret.row,
2216
+ row: editorStart + caret.row + topPad,
1846
2217
  column: Math.min(caret.column, width),
1847
2218
  },
1848
2219
  cursorVisible: state.status === 'compacting'
1849
2220
  || (options.inspected !== undefined && options.inspected.writable !== true)
1850
2221
  ? false
1851
- : promptSelector?.cursorVisible ?? (settings === undefined && copySelector === undefined),
2222
+ : promptSelector?.cursorVisible ?? (settings === undefined),
1852
2223
  liveStart,
1853
2224
  livePinned,
1854
2225
  // A transient frame is either a full-screen surface or the user browsing
@@ -1864,10 +2235,11 @@ export function renderView(state, options) {
1864
2235
  hiddenBelow: 0,
1865
2236
  }),
1866
2237
  blockStarts: transcript.blockStarts,
2238
+ fileRegions: transcript.fileRegions.map(region => ({ ...region, start: transcriptStart + topPad + region.start, end: transcriptStart + topPad + region.end })),
1867
2239
  blockDrawStarts: transcript.blockDrawStarts,
1868
2240
  foldShape: transcript.foldShape,
1869
2241
  foldMarks: transcript.foldMarks,
1870
- bodyRow: transcriptStart,
2242
+ bodyRow: transcriptStart + topPad,
1871
2243
  },
1872
2244
  };
1873
2245
  }