@agi-fans/dsh-tui 0.22.0 → 0.23.1

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 (282) hide show
  1. package/LICENSE +26 -0
  2. package/lib/CHANGELOG.md +219 -1
  3. package/lib/chrome/box.d.ts +1 -18
  4. package/lib/chrome/box.js +2 -45
  5. package/lib/chrome/box.js.map +1 -1
  6. package/lib/chrome/code-highlight.js +4 -4
  7. package/lib/chrome/code-highlight.js.map +1 -1
  8. package/lib/chrome/command-output.d.ts +0 -1
  9. package/lib/chrome/command-output.js +11 -5
  10. package/lib/chrome/command-output.js.map +1 -1
  11. package/lib/chrome/goal-bar.js +1 -1
  12. package/lib/chrome/goal-bar.js.map +1 -1
  13. package/lib/chrome/main-screen-renderer.js +4 -3
  14. package/lib/chrome/main-screen-renderer.js.map +1 -1
  15. package/lib/chrome/markdown.d.ts +3 -3
  16. package/lib/chrome/markdown.js +85 -36
  17. package/lib/chrome/markdown.js.map +1 -1
  18. package/lib/chrome/md-style.d.ts +5 -2
  19. package/lib/chrome/md-style.js +13 -4
  20. package/lib/chrome/md-style.js.map +1 -1
  21. package/lib/chrome/renderer.d.ts +37 -1
  22. package/lib/chrome/renderer.js.map +1 -1
  23. package/lib/chrome/status-line.d.ts +3 -3
  24. package/lib/chrome/status-line.js +5 -5
  25. package/lib/chrome/status-line.js.map +1 -1
  26. package/lib/chrome/theme.d.ts +8 -3
  27. package/lib/chrome/theme.js +51 -153
  28. package/lib/chrome/theme.js.map +1 -1
  29. package/lib/commands/auth-account.d.ts +9 -0
  30. package/lib/commands/auth-account.js +202 -0
  31. package/lib/commands/auth-account.js.map +1 -0
  32. package/lib/commands/auth.js +238 -414
  33. package/lib/commands/auth.js.map +1 -1
  34. package/lib/commands/diff.js +22 -16
  35. package/lib/commands/diff.js.map +1 -1
  36. package/lib/commands/export.d.ts +0 -1
  37. package/lib/commands/export.js +99 -32
  38. package/lib/commands/export.js.map +1 -1
  39. package/lib/commands/files.js +11 -9
  40. package/lib/commands/files.js.map +1 -1
  41. package/lib/commands/integrations.d.ts +12 -1
  42. package/lib/commands/integrations.js +120 -23
  43. package/lib/commands/integrations.js.map +1 -1
  44. package/lib/commands/jobs-browser.d.ts +3 -0
  45. package/lib/commands/jobs-browser.js +152 -0
  46. package/lib/commands/jobs-browser.js.map +1 -0
  47. package/lib/commands/jobs.d.ts +3 -14
  48. package/lib/commands/jobs.js +26 -18
  49. package/lib/commands/jobs.js.map +1 -1
  50. package/lib/commands/model.d.ts +3 -0
  51. package/lib/commands/model.js +212 -128
  52. package/lib/commands/model.js.map +1 -1
  53. package/lib/commands/open-external.d.ts +2 -0
  54. package/lib/commands/open-external.js +19 -0
  55. package/lib/commands/open-external.js.map +1 -0
  56. package/lib/commands/permission.js +65 -54
  57. package/lib/commands/permission.js.map +1 -1
  58. package/lib/commands/plugins.js +381 -139
  59. package/lib/commands/plugins.js.map +1 -1
  60. package/lib/commands/schedule-catalog.d.ts +12 -0
  61. package/lib/commands/schedule-catalog.js +41 -0
  62. package/lib/commands/schedule-catalog.js.map +1 -0
  63. package/lib/commands/schedule-history.d.ts +4 -0
  64. package/lib/commands/schedule-history.js +62 -0
  65. package/lib/commands/schedule-history.js.map +1 -0
  66. package/lib/commands/schedule.d.ts +12 -15
  67. package/lib/commands/schedule.js +220 -187
  68. package/lib/commands/schedule.js.map +1 -1
  69. package/lib/commands/session-configuration.d.ts +3 -0
  70. package/lib/commands/session-configuration.js +62 -28
  71. package/lib/commands/session-configuration.js.map +1 -1
  72. package/lib/commands/session.js +116 -66
  73. package/lib/commands/session.js.map +1 -1
  74. package/lib/commands/terminal.js +14 -8
  75. package/lib/commands/terminal.js.map +1 -1
  76. package/lib/definition.d.ts +115 -15
  77. package/lib/index.d.ts +1 -0
  78. package/lib/index.js +1 -0
  79. package/lib/index.js.map +1 -1
  80. package/lib/input/actions.d.ts +1 -1
  81. package/lib/input/actions.js +5 -2
  82. package/lib/input/actions.js.map +1 -1
  83. package/lib/input/composer-files.d.ts +2 -0
  84. package/lib/input/composer-files.js +13 -3
  85. package/lib/input/composer-files.js.map +1 -1
  86. package/lib/input/image-paste.d.ts +5 -12
  87. package/lib/input/image-paste.js +32 -28
  88. package/lib/input/image-paste.js.map +1 -1
  89. package/lib/input/key-test.d.ts +5 -0
  90. package/lib/input/key-test.js +19 -0
  91. package/lib/input/key-test.js.map +1 -0
  92. package/lib/input/keys.d.ts +2 -0
  93. package/lib/input/keys.js +4 -1
  94. package/lib/input/keys.js.map +1 -1
  95. package/lib/locale/index.d.ts +6 -0
  96. package/lib/locale/index.js +32 -0
  97. package/lib/locale/index.js.map +1 -0
  98. package/lib/locale/prompt.d.ts +4 -0
  99. package/lib/locale/prompt.js +24 -0
  100. package/lib/locale/prompt.js.map +1 -0
  101. package/lib/locale/zh.d.ts +1114 -0
  102. package/lib/locale/zh.js +1115 -0
  103. package/lib/locale/zh.js.map +1 -0
  104. package/lib/prompt-intents.d.ts +4 -0
  105. package/lib/prompt-intents.js +4 -0
  106. package/lib/prompt-intents.js.map +1 -1
  107. package/lib/runtime/account-settings.d.ts +18 -0
  108. package/lib/runtime/account-settings.js +241 -0
  109. package/lib/runtime/account-settings.js.map +1 -0
  110. package/lib/runtime/agent-profile.d.ts +3 -3
  111. package/lib/runtime/agent-profile.js +4 -4
  112. package/lib/runtime/agent-profile.js.map +1 -1
  113. package/lib/runtime/btw-policy.js +2 -1
  114. package/lib/runtime/btw-policy.js.map +1 -1
  115. package/lib/runtime/btw.js +1 -2
  116. package/lib/runtime/btw.js.map +1 -1
  117. package/lib/runtime/composer-images.d.ts +2 -2
  118. package/lib/runtime/composer-images.js +4 -4
  119. package/lib/runtime/composer-images.js.map +1 -1
  120. package/lib/runtime/composer-shell.js +27 -1
  121. package/lib/runtime/composer-shell.js.map +1 -1
  122. package/lib/runtime/file-browser.d.ts +19 -0
  123. package/lib/runtime/file-browser.js +291 -0
  124. package/lib/runtime/file-browser.js.map +1 -0
  125. package/lib/runtime/file-review.d.ts +6 -9
  126. package/lib/runtime/file-review.js +91 -7
  127. package/lib/runtime/file-review.js.map +1 -1
  128. package/lib/runtime/help-reader.d.ts +12 -0
  129. package/lib/runtime/help-reader.js +50 -0
  130. package/lib/runtime/help-reader.js.map +1 -0
  131. package/lib/runtime/human-interaction.js +1 -1
  132. package/lib/runtime/human-interaction.js.map +1 -1
  133. package/lib/runtime/model-catalog-editor.d.ts +12 -0
  134. package/lib/runtime/model-catalog-editor.js +215 -0
  135. package/lib/runtime/model-catalog-editor.js.map +1 -0
  136. package/lib/runtime/model-settings.d.ts +11 -0
  137. package/lib/runtime/model-settings.js +404 -0
  138. package/lib/runtime/model-settings.js.map +1 -0
  139. package/lib/runtime/plugin-settings.d.ts +4 -1
  140. package/lib/runtime/plugin-settings.js +164 -88
  141. package/lib/runtime/plugin-settings.js.map +1 -1
  142. package/lib/runtime/prompt-browser.d.ts +9 -0
  143. package/lib/runtime/prompt-browser.js +19 -0
  144. package/lib/runtime/prompt-browser.js.map +1 -0
  145. package/lib/runtime/provider-local.d.ts +27 -27
  146. package/lib/runtime/provider-local.js +1288 -308
  147. package/lib/runtime/provider-local.js.map +1 -1
  148. package/lib/runtime/reference-reader.d.ts +15 -0
  149. package/lib/runtime/reference-reader.js +78 -0
  150. package/lib/runtime/reference-reader.js.map +1 -0
  151. package/lib/runtime/runner.js +38 -29
  152. package/lib/runtime/runner.js.map +1 -1
  153. package/lib/runtime/session-runtime.js +1 -1
  154. package/lib/runtime/session-runtime.js.map +1 -1
  155. package/lib/runtime/settings-draft.d.ts +21 -0
  156. package/lib/runtime/settings-draft.js +166 -0
  157. package/lib/runtime/settings-draft.js.map +1 -0
  158. package/lib/runtime/settings-preferences.js +1 -1
  159. package/lib/runtime/settings-preferences.js.map +1 -1
  160. package/lib/runtime/startup-notices.d.ts +0 -1
  161. package/lib/runtime/startup-notices.js +35 -6
  162. package/lib/runtime/startup-notices.js.map +1 -1
  163. package/lib/runtime/subagent-model-selection.js +2 -1
  164. package/lib/runtime/subagent-model-selection.js.map +1 -1
  165. package/lib/runtime/subagent-settings.d.ts +14 -0
  166. package/lib/runtime/subagent-settings.js +288 -0
  167. package/lib/runtime/subagent-settings.js.map +1 -0
  168. package/lib/runtime/terminal-notifications.d.ts +3 -0
  169. package/lib/runtime/terminal-notifications.js +13 -8
  170. package/lib/runtime/terminal-notifications.js.map +1 -1
  171. package/lib/runtime/terminal-probe.d.ts +2 -0
  172. package/lib/runtime/terminal-probe.js +9 -0
  173. package/lib/runtime/terminal-probe.js.map +1 -1
  174. package/lib/runtime/tool-presentation.js.map +1 -1
  175. package/lib/runtime/tool-reader.d.ts +5 -0
  176. package/lib/runtime/tool-reader.js +27 -0
  177. package/lib/runtime/tool-reader.js.map +1 -0
  178. package/lib/runtime/web-settings.d.ts +14 -0
  179. package/lib/runtime/web-settings.js +339 -0
  180. package/lib/runtime/web-settings.js.map +1 -0
  181. package/lib/session/command-attachments.d.ts +17 -0
  182. package/lib/session/command-attachments.js +50 -0
  183. package/lib/session/command-attachments.js.map +1 -0
  184. package/lib/session/context-diagnostics.d.ts +16 -0
  185. package/lib/session/context-diagnostics.js +15 -0
  186. package/lib/session/context-diagnostics.js.map +1 -1
  187. package/lib/session/external-agents.d.ts +3 -1
  188. package/lib/session/external-agents.js +35 -10
  189. package/lib/session/external-agents.js.map +1 -1
  190. package/lib/session/generation-continuation.d.ts +11 -0
  191. package/lib/session/generation-continuation.js +34 -0
  192. package/lib/session/generation-continuation.js.map +1 -0
  193. package/lib/session/goal-controls.d.ts +5 -0
  194. package/lib/session/goal-controls.js +81 -0
  195. package/lib/session/goal-controls.js.map +1 -0
  196. package/lib/session/interaction-adapter.d.ts +7 -3
  197. package/lib/session/interaction-adapter.js +121 -49
  198. package/lib/session/interaction-adapter.js.map +1 -1
  199. package/lib/session/message-queue.d.ts +2 -0
  200. package/lib/session/message-queue.js +102 -17
  201. package/lib/session/message-queue.js.map +1 -1
  202. package/lib/session/pending-inputs.d.ts +30 -0
  203. package/lib/session/pending-inputs.js +183 -0
  204. package/lib/session/pending-inputs.js.map +1 -0
  205. package/lib/session/session-configuration.js +1 -1
  206. package/lib/session/session-configuration.js.map +1 -1
  207. package/lib/session/session-controller.d.ts +33 -20
  208. package/lib/session/session-controller.js +500 -129
  209. package/lib/session/session-controller.js.map +1 -1
  210. package/lib/session/session-library.d.ts +3 -1
  211. package/lib/session/session-library.js +15 -0
  212. package/lib/session/session-library.js.map +1 -1
  213. package/lib/session/subagent-roster.d.ts +1 -0
  214. package/lib/session/subagent-roster.js +16 -5
  215. package/lib/session/subagent-roster.js.map +1 -1
  216. package/lib/session/subagent-tracker.d.ts +2 -0
  217. package/lib/session/subagent-tracker.js +18 -0
  218. package/lib/session/subagent-tracker.js.map +1 -1
  219. package/lib/session/tui-settings.d.ts +9 -5
  220. package/lib/session/tui-settings.js +3 -1
  221. package/lib/session/tui-settings.js.map +1 -1
  222. package/lib/session/workflow-projection.d.ts +2 -1
  223. package/lib/session/workflow-projection.js +34 -4
  224. package/lib/session/workflow-projection.js.map +1 -1
  225. package/lib/views/autocomplete.d.ts +12 -12
  226. package/lib/views/autocomplete.js +54 -45
  227. package/lib/views/autocomplete.js.map +1 -1
  228. package/lib/views/context-overview.d.ts +4 -0
  229. package/lib/views/context-overview.js +43 -0
  230. package/lib/views/context-overview.js.map +1 -0
  231. package/lib/views/copy-targets.d.ts +6 -2
  232. package/lib/views/copy-targets.js +19 -14
  233. package/lib/views/copy-targets.js.map +1 -1
  234. package/lib/views/document-reader.d.ts +10 -1
  235. package/lib/views/document-reader.js +63 -6
  236. package/lib/views/document-reader.js.map +1 -1
  237. package/lib/views/event-views.js +29 -11
  238. package/lib/views/event-views.js.map +1 -1
  239. package/lib/views/file-actions.js +21 -0
  240. package/lib/views/file-actions.js.map +1 -1
  241. package/lib/views/file-browser.d.ts +98 -0
  242. package/lib/views/file-browser.js +120 -0
  243. package/lib/views/file-browser.js.map +1 -0
  244. package/lib/views/history-search.js +4 -4
  245. package/lib/views/history-search.js.map +1 -1
  246. package/lib/views/hotkey-format.d.ts +3 -2
  247. package/lib/views/hotkey-format.js +4 -3
  248. package/lib/views/hotkey-format.js.map +1 -1
  249. package/lib/views/hotkeys.js +10 -12
  250. package/lib/views/hotkeys.js.map +1 -1
  251. package/lib/views/list-view.js +16 -11
  252. package/lib/views/list-view.js.map +1 -1
  253. package/lib/views/prompt-selector.d.ts +23 -1
  254. package/lib/views/prompt-selector.js +123 -60
  255. package/lib/views/prompt-selector.js.map +1 -1
  256. package/lib/views/settings-list.d.ts +6 -1
  257. package/lib/views/settings-list.js +69 -46
  258. package/lib/views/settings-list.js.map +1 -1
  259. package/lib/views/tool-actions.d.ts +16 -0
  260. package/lib/views/tool-actions.js +29 -0
  261. package/lib/views/tool-actions.js.map +1 -0
  262. package/lib/views/trajectory-timeline.d.ts +5 -6
  263. package/lib/views/trajectory-timeline.js +25 -15
  264. package/lib/views/trajectory-timeline.js.map +1 -1
  265. package/lib/views/trajectory.d.ts +23 -2
  266. package/lib/views/trajectory.js +404 -186
  267. package/lib/views/trajectory.js.map +1 -1
  268. package/lib/views/transcript-render.d.ts +39 -15
  269. package/lib/views/transcript-render.js +406 -208
  270. package/lib/views/transcript-render.js.map +1 -1
  271. package/lib/views/transcript-types.d.ts +3 -2
  272. package/lib/views/transcript-types.js.map +1 -1
  273. package/lib/views/workflow.d.ts +16 -0
  274. package/lib/views/workflow.js +42 -0
  275. package/lib/views/workflow.js.map +1 -0
  276. package/package.json +61 -58
  277. package/lib/chrome/welcome-tips.d.ts +0 -23
  278. package/lib/chrome/welcome-tips.js +0 -24
  279. package/lib/chrome/welcome-tips.js.map +0 -1
  280. package/lib/session/steering.d.ts +0 -3
  281. package/lib/session/steering.js +0 -9
  282. package/lib/session/steering.js.map +0 -1
@@ -1,13 +1,16 @@
1
+ import { renderFileBrowser } from "./file-browser.js";
1
2
  /**
2
3
  * Transcript rendering: Block -> lines. Every function here is pure; the two
3
4
  * WeakMap caches key formatted rows on the immutable block objects the fold
4
5
  * half produces, so scrolling never reformats settled content.
5
6
  * @module @agi-fans/dsh-tui
6
7
  */
8
+ import { uiText } from "../locale/index.js";
9
+ import { rosterItems, workflowStatus } from "./workflow.js";
7
10
  import { leadingSlashCommandNameRange, renderAutocomplete, slashInlineHint } from "./autocomplete.js";
8
11
  import { HISTORY_SEARCH_MAX_VISIBLE, renderHistorySearch } from "./history-search.js";
9
12
  import { DEEP_DRIVING_LABEL, renderEditor, renderFramedBlock, renderWelcome } from "../chrome/box.js";
10
- import { renderMarkdown } from "../chrome/markdown.js";
13
+ import { renderMarkdown, renderStreamingMarkdown } from "../chrome/markdown.js";
11
14
  import { renderSettings } from "./settings-list.js";
12
15
  import { renderTrajectory } from "./trajectory.js";
13
16
  import { renderImagePreviewPage, renderPlanReviewPage, renderPromptSelector, renderPromptTreePage, } from "./prompt-selector.js";
@@ -16,7 +19,7 @@ import { renderPermissionBadge, renderStatusFooter } from "../chrome/status-line
16
19
  import { createTheme, paintSurface, SPINNER, SYMBOL, BOX } from "../chrome/theme.js";
17
20
  import { highlightToolText, toolPayloadLanguage } from "../chrome/code-highlight.js";
18
21
  import { renderGoalBar } from "../chrome/goal-bar.js";
19
- import { padToWidth, sliceCells, stripAnsi, truncateToWidth, visibleWidth, wrapTextStable } from "../chrome/width.js";
22
+ import { padToWidth, sliceCells, stripAnsi, truncateToWidth, visibleWidth, wrapCode, wrapTextStable } from "../chrome/width.js";
20
23
  import { alignFileDiffs, wrapPaintedDiffRows, } from "../chrome/diff-render.js";
21
24
  import { workspaceBlockLines, WORKSPACE_COLLAPSED_LINES } from "./workspace-changes.js";
22
25
  import { blockFileAction } from "./file-actions.js";
@@ -28,7 +31,7 @@ import { renderPathMentionRows } from "../chrome/path-mentions.js";
28
31
  import { formatElapsed } from "../chrome/relative-time.js";
29
32
  import { maskSecrets } from "../chrome/secret-mask.js";
30
33
  import { formatOverlayHint } from "./hotkey-format.js";
31
- import { blockMatchesQuery, matchedRows, searchMatches, transcriptSearchHint, } from "./transcript-search.js";
34
+ import { matchedRows, searchMatches, transcriptSearchHint, } from "./transcript-search.js";
32
35
  export { formatElapsed };
33
36
  import { contentToText, prettyArgs, reasoningKey, } from "./transcript-types.js";
34
37
  function isBlockPending(block) {
@@ -51,15 +54,7 @@ function exclusiveDiffs(presentation) {
51
54
  return presentation.call.diffs;
52
55
  return undefined;
53
56
  }
54
- /**
55
- * The transcript's one content column.
56
- *
57
- * Every block starts its words in the same column: a reply, a prompt after its
58
- * `›`, a run's header after its `▸`, a notice. The marks own the two cells to
59
- * the left of it. A reply used to sit one cell in while the marked rows put
60
- * their words at three, so a screen had four left edges and read as clutter
61
- * however tidy each block was on its own.
62
- */
57
+ /** Replies and user text have a two-cell margin; process details align after the disclosure glyph. */
63
58
  const ASSISTANT_PADDING_X = 2;
64
59
  /** Process summaries keep a readable measure even on wide terminals. */
65
60
  const PROCESS_WIDTH = 80;
@@ -87,9 +82,33 @@ function shortPath(path, width) {
87
82
  }
88
83
  return prefix + tail;
89
84
  }
90
- function assistantContentLines(lines, width, paddingX) {
85
+ // Streaming reuses most painted rows even though the enclosing block changes.
86
+ // Bound both row count and retained text, including ANSI and hyperlink bytes.
87
+ const assistantRowCache = new Map();
88
+ let assistantRowChars = 0;
89
+ const ASSISTANT_ROW_CACHE_CHARS = 1024 * 1024;
90
+ function assistantContentLines(lines, width, paddingX, streaming = false) {
91
91
  const margin = ' '.repeat(paddingX);
92
- return lines.map((line) => padToWidth(margin + line + margin, width));
92
+ if (!streaming)
93
+ return lines.map(line => padToWidth(margin + line + margin, width));
94
+ return lines.map(line => {
95
+ const key = `${width}:${paddingX}:${line}`;
96
+ const cached = assistantRowCache.get(key);
97
+ if (cached !== undefined)
98
+ return cached.row;
99
+ const row = padToWidth(margin + line + margin, width);
100
+ const size = key.length + row.length;
101
+ if (size <= ASSISTANT_ROW_CACHE_CHARS) {
102
+ while (assistantRowCache.size >= 4096 || assistantRowChars + size > ASSISTANT_ROW_CACHE_CHARS) {
103
+ const first = assistantRowCache.keys().next().value;
104
+ assistantRowChars -= assistantRowCache.get(first).size;
105
+ assistantRowCache.delete(first);
106
+ }
107
+ assistantRowCache.set(key, { row, size });
108
+ assistantRowChars += size;
109
+ }
110
+ return row;
111
+ });
93
112
  }
94
113
  function lockThinkingLine(line, theme) {
95
114
  if (line === '' || !theme.colors)
@@ -100,13 +119,13 @@ function hasExplicitTextColor(theme) {
100
119
  const ansi = theme.getFgAnsi('text');
101
120
  return ansi !== '' && ansi !== '\x1b[39m';
102
121
  }
103
- function assistantMarkdown(source, theme, width, style) {
122
+ function assistantMarkdown(source, theme, width, style, streaming = false) {
104
123
  const paddingX = width > ASSISTANT_PADDING_X * 2 ? ASSISTANT_PADDING_X : 0;
105
124
  const contentWidth = Math.max(1, width - paddingX * 2);
106
- const rendered = renderMarkdown(source, theme, contentWidth, style);
125
+ const rendered = (streaming ? renderStreamingMarkdown : renderMarkdown)(source, theme, contentWidth, style);
107
126
  if (style?.color !== 'thinkingText')
108
- return assistantContentLines(rendered, width, paddingX);
109
- return assistantContentLines(rendered.map(line => lockThinkingLine(line, theme)), width, paddingX);
127
+ return assistantContentLines(rendered, width, paddingX, streaming);
128
+ return assistantContentLines(rendered.map(line => lockThinkingLine(line, theme)), width, paddingX, streaming);
110
129
  }
111
130
  /**
112
131
  * What the reader typed.
@@ -185,7 +204,7 @@ function toolFailureFact(output, command, fact) {
185
204
  * Errors reserve space for their reason; full commands and output stay in the
186
205
  * expanded block. Process rows share an 80-cell measure and right padding.
187
206
  */
188
- function toolRowLine(block, theme, width, spinnerFrame) {
207
+ function toolRowLine(block, theme, width, spinnerFrame, open = false) {
189
208
  const presentation = renderTool({
190
209
  name: block.name,
191
210
  arguments: prettyArgs(block.args),
@@ -204,9 +223,9 @@ function toolRowLine(block, theme, width, spinnerFrame) {
204
223
  // two that remain are the states a reader has to act on: motion while the
205
224
  // call is in flight, and the error mark.
206
225
  const mark = block.status === 'ok' ? '' : toolStatusMark(block.status, theme, spinnerFrame) + ' ';
207
- const head = nest + mark;
226
+ const head = ' ' + nest + (open ? SYMBOL.unfolded : SYMBOL.folded) + ' ' + mark;
208
227
  const room = processRoom(width, head);
209
- const label = processLabel(block.name);
228
+ const label = theme.text(processLabel(block.name));
210
229
  const subject = toolRowSubject(block, presentation, label);
211
230
  const args = toolArgsObject(block.args);
212
231
  const call = block.presentation?.call;
@@ -289,8 +308,35 @@ function toolRowSubject(block, presentation, label) {
289
308
  const verb = label.split(' ')[0] ?? '';
290
309
  return firstLineOf(verb !== '' && title.startsWith(verb + ' ') ? title.slice(verb.length + 1) : title);
291
310
  }
311
+ /** Keep file line numbers outside tokenization and wrapping, including continuation rows. */
312
+ function readContentLines(view, theme, width, error) {
313
+ const language = toolPayloadLanguage('read', view.path);
314
+ const source = view.lines.map(line => line.text);
315
+ const painted = language === undefined
316
+ ? source.map(line => theme.fg(error ? 'error' : 'toolOutput', line))
317
+ : highlightedLines(source, language, theme, error ? 'error' : undefined);
318
+ const digits = view.lines.reduce((max, line) => Math.max(max, String(line.number).length), 1);
319
+ // Extremely narrow windows retain the file text rather than spending their
320
+ // final cell on a gutter. Ordinary widths reserve the same column on every row.
321
+ const gutter = width > digits + 2 ? digits + 2 : 0;
322
+ return view.lines.flatMap((line, index) => wrapCode(painted[index] ?? '', Math.max(1, width - gutter)).map((row, part) => {
323
+ const number = part === 0 ? String(line.number).padStart(digits) + ' ' : ' '.repeat(gutter);
324
+ return (gutter === 0 ? '' : theme.fg('dim', number)) + row;
325
+ }));
326
+ }
327
+ /** Results are plain output unless a read explicitly identifies source code. */
328
+ function toolOutputLines(block, output, subject, theme, width) {
329
+ const result = block.presentation?.result;
330
+ if (result?.card === 'read')
331
+ return readContentLines(result, theme, width, block.status === 'error');
332
+ const language = block.name === 'read' && result === undefined ? toolPayloadLanguage('read', subject) : undefined;
333
+ const painted = language === undefined
334
+ ? output.map(line => theme.fg(block.status === 'error' ? 'error' : 'toolOutput', line))
335
+ : highlightedLines(output, language, theme, block.status === 'error' ? 'error' : undefined);
336
+ return painted.flatMap(line => wrapTextStable(line, width));
337
+ }
292
338
  /** Tool content shared by a running turn and a completed turn opened for reading. */
293
- function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt+O', surface = 'toolSuccessBg') {
339
+ function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt+O', surface = 'toolSuccessBg', showTitle = true) {
294
340
  const full = mode !== 'preview';
295
341
  const presentation = renderTool({
296
342
  name: block.name, arguments: prettyArgs(block.args), output: block.output,
@@ -304,9 +350,9 @@ function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt
304
350
  const shell = COMMAND_TOOL.test(block.name) || call?.card === 'terminal';
305
351
  const read = block.name === 'read' || result?.card === 'read';
306
352
  const diffs = exclusiveDiffs(block.presentation);
307
- const padding = width > 4 ? 2 : 0;
353
+ const padding = width > 8 && !showTitle ? 4 : width > 4 ? 2 : 0;
308
354
  const nest = block.parentCallId === undefined || width < 10 ? 0 : 2;
309
- const room = Math.max(1, width - padding * 2 - nest);
355
+ const room = Math.max(1, width - padding - (width > 4 ? 2 : 0) - nest);
310
356
  const subject = toolArgSubject(block.args, block.partial === true)
311
357
  || (result?.card === 'read' ? result.path : '')
312
358
  || (args === undefined ? '' : Object.values(args).find(value => typeof value === 'string')) || '';
@@ -316,15 +362,13 @@ function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt
316
362
  : call?.card === 'generic' || call?.card === 'diff' ? call.title
317
363
  : !read && presentation.title !== undefined && presentation.title !== block.name ? presentation.title
318
364
  : `${block.name}${subject === '' ? '' : ` ${subject}`}`;
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.
365
+ // Commands and their output use plain ink; only identified file content is tokenized.
326
366
  const titleInk = block.status === 'error' ? 'error' : 'toolTitle';
327
- const rows = wrapTextStable(theme.bold(theme.fg(titleInk, title)), room);
367
+ const rows = showTitle || mode !== 'details' && shell && command !== ''
368
+ ? wrapTextStable(theme.bold(theme.fg(titleInk, title)), room) : [];
369
+ if (!showTitle && mode !== 'details' && result?.card === 'read' && result.lines.length > 0) {
370
+ rows.push(...wrapTextStable(theme.dim(`${result.path} · ${result.lines[0].number}–${result.lines.at(-1).number} / ${result.totalLines}`), room));
371
+ }
328
372
  let body = [];
329
373
  if (mode === 'details') {
330
374
  let input = block.args;
@@ -333,19 +377,15 @@ function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt
333
377
  }
334
378
  catch { /* Partial arguments retain their received text. */ }
335
379
  const output = presentation.output.length > 0 ? presentation.output : block.output.split('\n');
336
- const detailLanguage = shell ? 'bash' : toolPayloadLanguage(block.name, subject);
337
380
  const outputRows = diffs !== undefined
338
381
  ? 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));
382
+ : toolOutputLines(block, output, subject, theme, room);
343
383
  body = [
344
- ...wrapTextStable(theme.fg('dim', 'Input'), room),
384
+ ...wrapTextStable(theme.fg('dim', theme.text('Input')), room),
345
385
  // The arguments are JSON here (they were re-serialized above), so they are
346
386
  // highlighted as JSON whatever the tool itself reads.
347
387
  ...highlightedLines(input.split('\n'), 'json', theme, undefined, room),
348
- '', ...wrapTextStable(theme.fg('dim', 'Output'), room),
388
+ '', ...wrapTextStable(theme.fg('dim', theme.text('Output')), room),
349
389
  ...outputRows,
350
390
  ];
351
391
  // Keep the tool's rich presentation, but expose received text it replaced.
@@ -358,20 +398,15 @@ function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt
358
398
  }
359
399
  else if (diffs !== undefined) {
360
400
  body = wrapPaintedDiffRows(alignFileDiffs(diffs), theme, room);
401
+ if (block.status === 'error' && block.output !== '')
402
+ body.push(...wrapTextStable(theme.fg('error', block.output), room));
361
403
  }
362
404
  else if (full || !read || block.status === 'error') {
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);
371
- const input = !shell && !read && (call?.card === 'generic' || (call === undefined && presentation.title !== block.name))
372
- ? highlightedLines(presentation.input, language ?? 'text', theme, undefined, room)
405
+ const output = toolOutputLines(block, presentation.output, subject, theme, room);
406
+ const input = presentation.input.length > 0 && !shell && !read && (call?.card === 'generic' || (call === undefined && !/^(grep|glob|find|ls|search|write|edit)$/.test(block.name)))
407
+ ? presentation.input.flatMap(line => wrapCode(theme.fg('toolOutput', line), room))
373
408
  : [];
374
- body = [...input, ...output.flatMap(line => wrapTextStable(line, room))];
409
+ body = [...input, ...output];
375
410
  // Writes carry their content in arguments before the result arrives.
376
411
  if (body.length === 0 && block.name === 'write' && typeof args?.['content'] === 'string') {
377
412
  body = highlightedLines(args['content'].split('\n'), toolPayloadLanguage('write', subject) ?? 'text', theme, undefined, room);
@@ -394,13 +429,17 @@ function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt
394
429
  if (!full && read && block.status !== 'error' && block.output !== '' && detailsKey !== 'Disabled') {
395
430
  body.push(...wrapTextStable(theme.fg('dim', `${detailsKey}: Expand result`), room));
396
431
  }
397
- if (full && block.status === 'error' && call?.card === 'diff')
432
+ if (full && diffs === undefined && block.status === 'error' && call?.card === 'diff')
398
433
  rows.push('', ...wrapPaintedDiffRows(alignFileDiffs(call.diffs), theme, room));
399
434
  if (body.length > 0) {
400
- rows.push('');
435
+ if (rows.length > 0)
436
+ rows.push('');
401
437
  for (const row of body)
402
438
  rows.push(row);
403
439
  }
440
+ if (!showTitle && mode !== 'details' && presentation.summary && (result?.card === 'terminal' || result?.card === 'search')) {
441
+ rows.push(...wrapTextStable(theme.dim(presentation.summary), room));
442
+ }
404
443
  if (block.status === 'error')
405
444
  rows.push(...wrapTextStable(theme.fg('error', 'Tool failed'), room));
406
445
  if (block.job !== undefined) {
@@ -410,7 +449,7 @@ function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt
410
449
  rows.push('');
411
450
  rows.push(...wrapTextStable(theme.fg(block.status === 'error' ? 'error' : 'muted', `Job ${block.job.id} · ${block.job.detail ?? block.job.status}`), room));
412
451
  }
413
- const bg = block.status === 'running' ? 'toolPendingBg' : block.status === 'error' ? 'toolErrorBg' : surface;
452
+ const bg = !showTitle ? 'toolAltBg' : block.status === 'running' ? 'toolPendingBg' : block.status === 'error' ? 'toolErrorBg' : surface;
414
453
  return ['', ...rows, ''].map(row => paintSurface(theme, bg, 'toolOutput', padToWidth(' '.repeat(padding + nest) + row, width)));
415
454
  }
416
455
  function firstLineOf(text) {
@@ -421,21 +460,43 @@ function firstLineOf(text) {
421
460
  }
422
461
  return '';
423
462
  }
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) {
463
+ /** Latest stable paragraph heading; streaming fragments never flicker into the preview. */
464
+ function reasoningPreview(block) {
465
+ const paragraphs = block.reasoning.replace(/\r\n/g, '\n').split(/\n[ \t]*\n/);
466
+ for (let index = paragraphs.length - 1; index >= 0; index--) {
467
+ const paragraph = paragraphs[index];
468
+ if (block.streaming && index === paragraphs.length - 1 && !paragraph.includes('\n'))
469
+ continue;
470
+ const first = firstLineOf(paragraph);
471
+ if (first !== '')
472
+ return first;
473
+ }
474
+ return '';
475
+ }
476
+ function reasoningSummaryRow(block, theme, width) {
431
477
  const paddingX = width > ASSISTANT_PADDING_X * 2 ? ASSISTANT_PADDING_X : 0;
432
- const label = `Thinking: ${firstLineOf(reasoning)}`;
478
+ const preview = reasoningPreview(block);
479
+ const label = `${SYMBOL.folded} ${theme.text('Thinking')}${preview === '' ? '' : ROW_JOIN + preview}`;
433
480
  return assistantContentLines([theme.fg('dim', truncateToWidth(label, Math.max(1, width - paddingX * 2)))], width, paddingX)[0]
434
481
  ?? '';
435
482
  }
483
+ /** A disclosure header stays small; complete tool payloads are laid out only on demand. */
484
+ function toolDisclosureLines(block, theme, width, open, detailsKey = 'Alt+O', spinnerFrame = 0, raw = false) {
485
+ const rows = toolRowLine(block, theme, width, spinnerFrame, open);
486
+ if (open)
487
+ rows.push(...toolBlockLines(block, theme, width, raw ? 'details' : 'full', detailsKey, 'toolAltBg', false));
488
+ return rows;
489
+ }
490
+ function reasoningDisclosureLines(block, theme, width, open, mathMode = 'auto') {
491
+ if (!open)
492
+ return [reasoningSummaryRow(block, theme, width)];
493
+ const indent = width > 8 ? 2 : 0;
494
+ return [padToWidth(truncateToWidth(` ${SYMBOL.unfolded} ${theme.text('Thinking')}`, Math.max(1, width - 2)), width),
495
+ ...openReasoningLines(block, theme, width - indent, mathMode).map(line => ' '.repeat(indent) + line)];
496
+ }
436
497
  /** Thinking uses the same quiet Markdown style throughout the turn. */
437
498
  function openReasoningLines(block, theme, width, mathMode = 'auto') {
438
- return assistantMarkdown(block.reasoning, theme, width, { color: 'thinkingText', italic: true, mathMode });
499
+ return assistantMarkdown(block.reasoning, theme, width, { color: 'thinkingText', italic: true, mathMode }, block.streaming);
439
500
  }
440
501
  /** The block indexes a run shows as rows, in order: its members, then the thought it took from its answer. */
441
502
  function groupMembers(group) {
@@ -453,37 +514,38 @@ function groupMembers(group) {
453
514
  * last row and its words are the answer painted below the run, so the run
454
515
  * paints the thought and nothing else.
455
516
  */
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) {
517
+ function groupMemberLines(index, context, thoughtOnly = false) {
459
518
  const { blocks, options, theme, width } = context;
460
519
  const block = blocks[index];
461
520
  // Search must reveal a match even when it falls outside a tool's normal preview.
462
- const full = block.kind === 'tool' && (context.matches.has(index) || options.expandedTools?.has(block.callId) === true);
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');
521
+ const full = block.kind === 'tool' && (options.toolsExpanded === true || context.matches.has(index) || options.expandedTools?.has(block.callId) === true);
522
+ const raw = block.kind === 'tool' && (options.toolsExpanded === true || options.rawTools?.has(block.callId) === true || context.matches.has(index));
523
+ const thinkingOpen = options.hideThinking === false
524
+ || (block.kind === 'assistant' && (context.openedThoughts.has(reasoningKey(block)) || context.matches.has(index)));
525
+ const signature = [width, options.colors, context.depth, context.themeName, terminalKey(options), options.mathMode, options.mermaidMode, thoughtOnly, full, raw, options.toolDetailsKey, block.kind === 'tool' && block.status === 'running' ? context.spinnerFrame : 0, thinkingOpen, options.hideThinking !== false].join('\0');
467
526
  const cached = groupMemberCache.get(block);
468
527
  let lines;
469
528
  if (cached?.signature === signature)
470
529
  lines = cached.lines;
471
530
  else {
472
- lines = block.kind === 'tool' ? toolBlockLines(block, theme, width, full ? 'details' : 'preview', options.toolDetailsKey, surface)
531
+ lines = block.kind === 'tool' ? toolDisclosureLines(block, theme, width, full, options.toolDetailsKey, context.spinnerFrame, raw)
473
532
  : block.kind === 'assistant' ? thoughtOnly
474
- ? (options.hideThinking !== true || thinkingOpen
475
- ? openReasoningLines(block, theme, width, options.mathMode)
476
- : [reasoningSummaryRow(block.reasoning, theme, width)])
533
+ ? reasoningDisclosureLines(block, theme, width, thinkingOpen, options.mathMode)
477
534
  : blockLines(block, theme, width, {
478
535
  mathMode: options.mathMode ?? 'auto',
479
536
  mermaidMode: options.mermaidMode ?? 'auto',
480
- hideThinking: options.hideThinking === true,
537
+ hideThinking: options.hideThinking !== false,
481
538
  thinkingOpen,
539
+ processDisclosure: true,
482
540
  })
483
541
  : block.kind === 'notice' ? blockLines(block, theme, width) : [];
484
542
  groupMemberCache.set(block, { signature, lines });
485
543
  }
486
- return [...paintSearch(lines, index, context.occurrences, theme)];
544
+ const painted = [...paintSearch(lines, index, context.occurrences, theme)];
545
+ if (options.selectedProcess === index && painted.length > 0) {
546
+ painted[0] = theme.fg('accent', '❯') + sliceCells(painted[0], 1, Math.max(0, width - 1));
547
+ }
548
+ return painted;
487
549
  }
488
550
  /**
489
551
  * Paint the query occurrences of one block onto its rows.
@@ -570,11 +632,14 @@ function processGroupBody(group, context) {
570
632
  const offsets = new Map();
571
633
  const spans = [];
572
634
  for (const index of groupMembers(group)) {
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;
577
- if (rows.length > 0 && lines.length > 0)
635
+ const block = context.blocks[index];
636
+ const rows = groupMemberLines(index, context, index === group.answer && group.tookThought);
637
+ const previous = context.blocks[index - 1];
638
+ const opened = (item, at) => item?.kind === 'tool'
639
+ ? context.options.toolsExpanded === true || context.options.expandedTools?.has(item.callId) === true || context.matches.has(at)
640
+ : item?.kind === 'assistant' && item.reasoning !== '' && (context.options.hideThinking === false || context.openedThoughts.has(reasoningKey(item)));
641
+ const reply = block.kind === 'assistant' && block.text !== '' && index !== group.answer;
642
+ if (rows.length > 0 && lines.length > 0 && (opened(block, index) || opened(previous, index - 1) || reply || previous?.kind === 'assistant' && previous.text !== ''))
578
643
  lines.push('');
579
644
  offsets.set(index, lines.length);
580
645
  if (context.blocks[index]?.kind === 'tool')
@@ -588,8 +653,8 @@ function processGroupBody(group, context) {
588
653
  function processGroupHeader(group, theme, width, elapsed, expandKey = 'Ctrl+O') {
589
654
  const label = elapsed === undefined ? 'Worked' : `Worked for ${elapsed}`;
590
655
  const failure = group.failures === 0 ? '' : theme.fg('error', ` · ${group.failures} failed`);
591
- const labelRoom = Math.max(1, width - 4 - visibleWidth(failure));
592
- const summary = `${SYMBOL.folded} ${theme.fg('dim', truncateToWidth(label, labelRoom))}${failure}`;
656
+ const labelRoom = Math.max(1, width - 6 - visibleWidth(failure));
657
+ const summary = ` ${SYMBOL.folded} ${theme.fg('dim', truncateToWidth(label, labelRoom))}${failure}`;
593
658
  const hint = ` · ${expandKey} to expand`;
594
659
  const suffix = expandKey !== 'Disabled' && visibleWidth(summary) + visibleWidth(hint) <= width - 2
595
660
  ? theme.fg('dim', hint) : '';
@@ -600,15 +665,15 @@ function processGroupHeader(group, theme, width, elapsed, expandKey = 'Ctrl+O')
600
665
  * to direct callers; turn inspection uses previews. omitReasoning excludes
601
666
  * thinking already drawn by a run.
602
667
  */
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' } = {}) {
668
+ export function blockLines(block, theme, width, { toolsExpanded = false, omitReasoning = false, toolDetailsKey = 'Alt+O', mathMode = 'auto', mermaidMode = 'auto', hideThinking = false, thinkingOpen = false, processDisclosure = false, rawTool = false, expandedOutput = false, outputExpandKey = 'Ctrl+O' } = {}) {
604
669
  if (block.kind === 'user')
605
670
  return userBubble(block.text, theme, width);
606
671
  if (block.kind === 'assistant') {
607
672
  const lines = [];
608
673
  if (block.reasoning !== '' && !omitReasoning) {
609
674
  const collapsed = hideThinking && !thinkingOpen;
610
- lines.push(...(collapsed
611
- ? [reasoningSummaryRow(block.reasoning, theme, width)]
675
+ lines.push(...(processDisclosure ? reasoningDisclosureLines(block, theme, width, !collapsed, mathMode) : collapsed
676
+ ? [reasoningSummaryRow(block, theme, width)]
612
677
  : openReasoningLines(block, theme, width, mathMode)));
613
678
  if (block.text !== '')
614
679
  lines.push('');
@@ -618,7 +683,7 @@ export function blockLines(block, theme, width, { toolsExpanded = false, omitRea
618
683
  lines.push(...assistantContentLines([theme.fg('dim', '…')], width, paddingX));
619
684
  }
620
685
  else if (block.text !== '') {
621
- lines.push(...assistantMarkdown(block.text, theme, width, { ...(hasExplicitTextColor(theme) ? { color: 'text' } : {}), mathMode, mermaidMode }));
686
+ lines.push(...assistantMarkdown(block.text, theme, width, { ...(hasExplicitTextColor(theme) ? { color: 'text' } : {}), mathMode, mermaidMode }, block.streaming));
622
687
  }
623
688
  if (block.interrupted === true) {
624
689
  const paddingX = width > ASSISTANT_PADDING_X * 2 ? ASSISTANT_PADDING_X : 0;
@@ -627,7 +692,8 @@ export function blockLines(block, theme, width, { toolsExpanded = false, omitRea
627
692
  return lines;
628
693
  }
629
694
  if (block.kind === 'tool') {
630
- return toolBlockLines(block, theme, width, toolsExpanded ? 'full' : 'preview', toolDetailsKey);
695
+ return processDisclosure ? toolDisclosureLines(block, theme, width, toolsExpanded, toolDetailsKey, 0, rawTool)
696
+ : toolBlockLines(block, theme, width, toolsExpanded ? 'full' : 'preview', toolDetailsKey);
631
697
  }
632
698
  if (block.kind === 'workspace')
633
699
  return workspaceBlockLines(block, theme, width, toolsExpanded);
@@ -657,6 +723,12 @@ export function blockLines(block, theme, width, { toolsExpanded = false, omitRea
657
723
  return [renderTranscriptBoundary(block.text, theme, width)];
658
724
  if (block.process !== undefined && block.framed !== true)
659
725
  return [processNoticeLine(block, theme, width, '')];
726
+ if (block.detail !== undefined) {
727
+ const head = ' ' + theme.fg('dim', block.text);
728
+ return expandedOutput
729
+ ? [truncateToWidth(head, width), '', ...renderMarkdown(block.detail, theme, Math.max(1, width - 4)).map(line => ' ' + line)]
730
+ : [truncateToWidth(head + theme.fg('dim', ' · ' + outputExpandKey + ': ' + theme.text('Expand')), width)];
731
+ }
660
732
  if (block.framed !== true) {
661
733
  const prefix = ' ';
662
734
  const continuation = ' '.repeat(visibleWidth(prefix));
@@ -723,15 +795,9 @@ function renderTranscriptBoundary(label, theme, width) {
723
795
  */
724
796
  const renderedBlockCache = new WeakMap();
725
797
  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');
798
+ const theme = createTheme(options.colors === true, options.colorDepth ?? 'truecolor', options.themeName ?? 'dark', options.terminal, options.language, options.compactLinks);
799
+ const key = [options.width, options.colors, options.colorDepth, options.themeName,
800
+ options.language, options.compactLinks, JSON.stringify(options.terminal), options.mathMode, options.mermaidMode].join('\0');
735
801
  const cached = renderedBlockCache.get(state.blocks)?.get(key);
736
802
  if (cached !== undefined)
737
803
  return cached;
@@ -748,15 +814,10 @@ export function renderedBlocks(state, options) {
748
814
  }
749
815
  /** Plain rows one block contributes, without the search highlighting applied. */
750
816
  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');
817
+ return blockLines(block, theme, options.width, {
818
+ processDisclosure: true, toolsExpanded: true, rawTool: true, thinkingOpen: true,
819
+ expandedOutput: true, mathMode: options.mathMode ?? 'auto', mermaidMode: options.mermaidMode ?? 'auto',
820
+ });
760
821
  }
761
822
  /** Plain text of one block, used by transcript search and match painting. */
762
823
  export function blockSearchText(block) {
@@ -772,6 +833,8 @@ export function blockSearchText(block) {
772
833
  }
773
834
  if (block.kind === 'shell')
774
835
  return `${block.command}\n${block.output}`;
836
+ if (block.kind === 'notice' && block.detail !== undefined)
837
+ return `${block.text}\n${block.detail}`;
775
838
  return block.text;
776
839
  }
777
840
  function fitFrame(lines, width, stablePrefix = 0) {
@@ -796,6 +859,7 @@ const transcriptBodyCache = new WeakMap();
796
859
  /** Settled immutable blocks keep their expensive Markdown/tool layout. */
797
860
  const blockLinesCache = new WeakMap();
798
861
  function cachedBlockLines(block, options, theme, themeName, depth, expanded, omitReasoning = false) {
862
+ const rawTool = block.kind === 'tool' && (options.toolsExpanded === true || options.rawTools?.has(block.callId) === true || options.transcriptSearch !== undefined && options.transcriptSearch.query.trim() !== '');
799
863
  const cached = blockLinesCache.get(block);
800
864
  if (cached !== undefined
801
865
  && cached.width === options.width
@@ -805,26 +869,29 @@ function cachedBlockLines(block, options, theme, themeName, depth, expanded, omi
805
869
  && cached.terminalKey === terminalKey(options)
806
870
  && cached.expanded === expanded
807
871
  && cached.omitReasoning === omitReasoning
808
- && cached.hideThinking === (options.hideThinking === true)
872
+ && cached.hideThinking === (options.hideThinking !== false)
809
873
  && cached.thinkingOpen === (block.kind === 'assistant' && options.expandedReasoning?.has(reasoningKey(block)) === true)
810
874
  && cached.mathMode === options.mathMode
811
875
  && cached.mermaidMode === options.mermaidMode
812
876
  && cached.toolDetailsKey === options.toolDetailsKey
877
+ && cached.rawTool === rawTool
813
878
  && cached.toolDetails === (block.kind === 'tool' && options.expandedTools?.has(block.callId) === true)
814
879
  && cached.expandedOutput === (options.expandedOutputs?.has(block) === true))
815
880
  return cached.lines;
816
881
  const toolDetails = block.kind === 'tool' && options.expandedTools?.has(block.callId) === true;
817
882
  const lines = block.kind === 'tool' && toolDetails
818
- ? toolBlockLines(block, theme, options.width, 'details', options.toolDetailsKey)
883
+ ? toolDisclosureLines(block, theme, options.width, true, options.toolDetailsKey, 0, rawTool)
819
884
  : blockLines(block, theme, options.width, {
820
885
  toolsExpanded: expanded,
886
+ rawTool,
887
+ processDisclosure: true,
821
888
  omitReasoning,
822
889
  toolDetailsKey: options.toolDetailsKey ?? 'Alt+O',
823
890
  mathMode: options.mathMode ?? 'auto',
824
891
  mermaidMode: options.mermaidMode ?? 'auto',
825
- hideThinking: options.hideThinking === true,
892
+ hideThinking: options.hideThinking !== false,
826
893
  thinkingOpen: block.kind === 'assistant' && options.expandedReasoning?.has(reasoningKey(block)) === true,
827
- expandedOutput: options.expandedOutputs?.has(block) === true,
894
+ expandedOutput: options.expandedOutputs?.has(block) === true || (block.kind === 'notice' && expanded),
828
895
  outputExpandKey: options.turnDetailsKey ?? 'Ctrl+O',
829
896
  });
830
897
  blockLinesCache.set(block, {
@@ -832,6 +899,7 @@ function cachedBlockLines(block, options, theme, themeName, depth, expanded, omi
832
899
  mermaidMode: options.mermaidMode,
833
900
  toolDetailsKey: options.toolDetailsKey,
834
901
  toolDetails,
902
+ rawTool,
835
903
  expandedOutput: options.expandedOutputs?.has(block) === true,
836
904
  width: options.width,
837
905
  colors: options.colors,
@@ -840,7 +908,7 @@ function cachedBlockLines(block, options, theme, themeName, depth, expanded, omi
840
908
  terminalKey: terminalKey(options),
841
909
  expanded,
842
910
  omitReasoning,
843
- hideThinking: options.hideThinking === true,
911
+ hideThinking: options.hideThinking !== false,
844
912
  thinkingOpen: block.kind === 'assistant' && options.expandedReasoning?.has(reasoningKey(block)) === true,
845
913
  lines,
846
914
  });
@@ -1072,12 +1140,13 @@ export function turnGroups(state) {
1072
1140
  function renderTranscriptBody(state, options, theme, spinnerFrame) {
1073
1141
  const toolsExpanded = options.toolsExpanded === true;
1074
1142
  const expandedTools = [...(options.expandedTools ?? [])].sort().join('\0');
1143
+ const rawTools = [...(options.rawTools ?? [])].sort().join('\0');
1075
1144
  const expandedReasoning = [...(options.expandedReasoning ?? [])].sort().join('\0');
1076
- const hideThinking = options.hideThinking === true;
1145
+ const hideThinking = options.hideThinking !== false;
1077
1146
  const openedGroups = [...(options.openedGroups ?? [])].sort().join('\0');
1078
1147
  // A command output the reader opened paints different rows, so the body cache
1079
1148
  // has to see it; without this the frame is reused and the key looks dead.
1080
- const expandedOutputs = [...(options.expandedOutputs ?? [])].join('\u0000');
1149
+ const expandedOutputs = state.blocks.flatMap((block, index) => options.expandedOutputs?.has(block) ? [index] : []).join(',');
1081
1150
  const themeName = options.themeName ?? 'dark';
1082
1151
  const depth = colorDepthOf(options);
1083
1152
  const animatedSpinnerFrame = state.blocks.some(block => block.kind === 'tool' && block.status === 'running')
@@ -1109,6 +1178,7 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1109
1178
  && cached.mermaidMode === options.mermaidMode
1110
1179
  && cached.toolsExpanded === toolsExpanded
1111
1180
  && cached.expandedTools === expandedTools
1181
+ && cached.rawTools === rawTools
1112
1182
  && cached.expandedReasoning === expandedReasoning
1113
1183
  && cached.hideThinking === hideThinking
1114
1184
  && cached.openedGroups === openedGroups
@@ -1118,9 +1188,11 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1118
1188
  && cached.turnDetailsKey === options.turnDetailsKey
1119
1189
  && cached.searchKey === searchKey
1120
1190
  && cached.status === state.status
1121
- && cached.focusBlock === options.focusBlock) {
1191
+ && cached.focusBlock === options.focusBlock
1192
+ && cached.selectedProcess === options.selectedProcess) {
1122
1193
  return {
1123
1194
  lines: cached.lines,
1195
+ disclosures: cached.disclosures,
1124
1196
  fileRegions: cached.fileRegions,
1125
1197
  blockStarts: cached.blockStarts,
1126
1198
  blockDrawStarts: cached.blockDrawStarts,
@@ -1135,8 +1207,25 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1135
1207
  const matches = new Set(occurrences.map(match => match.block));
1136
1208
  const lines = [];
1137
1209
  const fileRegions = [];
1138
- const filesAt = (index, start, length, expanded = false) => {
1210
+ const disclosures = [];
1211
+ const disclosureByBlock = new Map();
1212
+ const disclosureAt = (index, row) => {
1139
1213
  const block = state.blocks[index];
1214
+ if (block.kind !== 'tool' && !(block.kind === 'assistant' && block.reasoning.trim() !== ''))
1215
+ return;
1216
+ const column = block.kind === 'tool' && block.parentCallId !== undefined ? 4 : 2;
1217
+ if (options.width < column + 4)
1218
+ return;
1219
+ const target = { block: index, row, column: 0, width: column + 2,
1220
+ key: block.kind === 'tool' ? `call:${block.callId}` : `thought:${reasoningKey(block)}` };
1221
+ disclosures.push(target);
1222
+ disclosureByBlock.set(index, target);
1223
+ };
1224
+ const regionsAt = (index, start, length, expanded = false) => {
1225
+ const block = state.blocks[index];
1226
+ const target = disclosureByBlock.get(index);
1227
+ if (block.kind === 'tool' && target !== undefined && length > 1)
1228
+ target.detailEnd = start + length;
1140
1229
  if (length === 0 || blockFileAction(block) === undefined)
1141
1230
  return;
1142
1231
  if (block.kind === 'workspace') {
@@ -1165,6 +1254,11 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1165
1254
  const absorbed = new Set();
1166
1255
  // Thoughts a search hit has to show in full, beyond what the reader opened.
1167
1256
  const openedThoughts = new Set(options.expandedReasoning ?? []);
1257
+ for (const index of matches) {
1258
+ const block = state.blocks[index];
1259
+ if (block?.kind === 'assistant' && block.reasoning !== '')
1260
+ openedThoughts.add(reasoningKey(block));
1261
+ }
1168
1262
  // Where each answer's thought is actually drawn, for its fold mark. The
1169
1263
  // navigation offset below may point elsewhere; the mark must not follow it.
1170
1264
  const drawnThoughts = new Map();
@@ -1181,21 +1275,9 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1181
1275
  absorbed.add(group.answer);
1182
1276
  if (group.turn !== undefined)
1183
1277
  lastOfTurn.set(group.turn, index);
1184
- const reasoningHit = group.tookThought && group.answer !== undefined
1185
- && search !== undefined && matches.has(group.answer)
1186
- && blockMatchesQuery(state.blocks[group.answer].reasoning, search.query);
1187
- // A hit inside the thought the run took needs that thought laid out in
1188
- // full. Opening only the run shows a one-line preview, so the matched text
1189
- // stays out of sight while the transcript reports the hit. This is a render
1190
- // decision, not the reader's own state, so it goes in a local set rather
1191
- // than into the options the host owns.
1192
- if (reasoningHit && group.answer !== undefined) {
1193
- openedThoughts.add(reasoningKey(state.blocks[group.answer]));
1194
- }
1195
1278
  const asked = toolsExpanded
1196
1279
  || options.openedGroups?.has(group.key) === true
1197
1280
  || state.blocks.slice(group.start, group.until).some(block => block.kind === 'tool' && options.expandedTools?.has(block.callId))
1198
- || reasoningHit
1199
1281
  || [...matches].some(index => index >= group.start && index < group.until)
1200
1282
  || options.focusBlock !== undefined
1201
1283
  && options.focusBlock >= group.start && options.focusBlock < group.until;
@@ -1216,7 +1298,7 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1216
1298
  blocks: state.blocks, theme, themeName, depth, width: options.width,
1217
1299
  matches, occurrences, options: { ...options, expandedReasoning: openedThoughts },
1218
1300
  openedThoughts,
1219
- toolCards: { count: 0 },
1301
+ spinnerFrame,
1220
1302
  };
1221
1303
  const compactProcessRow = (block) => {
1222
1304
  if (block?.kind === 'notice')
@@ -1263,6 +1345,8 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1263
1345
  liveFrom ??= headerRow;
1264
1346
  // A live run has no header: it becomes a group when its turn ends.
1265
1347
  if (!group.live && !shown) {
1348
+ if (options.width >= 6)
1349
+ disclosures.push({ row: headerRow, column: 0, width: 4, block: group.start, key: `run:${group.key}`, group: group.key });
1266
1350
  lines.push(...processGroupHeader(group, theme, options.width, elapsedOf(groupIndex), options.turnDetailsKey));
1267
1351
  }
1268
1352
  const bodyRow = lines.length;
@@ -1272,18 +1356,26 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1272
1356
  for (const line of body.lines)
1273
1357
  lines.push(line);
1274
1358
  offsets = body.offsets;
1359
+ for (const [index, offset] of offsets)
1360
+ disclosureAt(index, bodyRow + offset);
1275
1361
  for (const span of body.spans)
1276
- filesAt(span.index, bodyRow + span.start, span.end - span.start);
1362
+ regionsAt(span.index, bodyRow + span.start, span.end - span.start);
1277
1363
  }
1278
1364
  else {
1279
1365
  for (let at = group.start; at < (group.answer ?? group.until); at += 1) {
1280
1366
  const member = state.blocks[at];
1281
- if (!(member.kind === 'tool' && member.status === 'error')
1367
+ const reply = member.kind === 'assistant' && member.text !== '';
1368
+ if (!reply && !(member.kind === 'tool' && member.status === 'error')
1282
1369
  && !(member.kind === 'notice' && member.level === 'error'))
1283
1370
  continue;
1371
+ if (reply && lines.length > 0)
1372
+ lines.push('');
1284
1373
  offsets.set(at, lines.length - bodyRow);
1285
- const rows = member.kind === 'tool' ? toolRowLine(member, theme, options.width, spinnerFrame) : groupMemberLines(at, context);
1286
- filesAt(at, lines.length, rows.length);
1374
+ const rows = member.kind === 'tool' ? toolRowLine(member, theme, options.width, spinnerFrame)
1375
+ : reply ? cachedBlockLines(member, options, theme, themeName, depth, toolsExpanded, true) : groupMemberLines(at, context);
1376
+ if (member.kind === 'tool')
1377
+ disclosureAt(at, lines.length);
1378
+ regionsAt(at, lines.length, rows.length);
1287
1379
  lines.push(...rows);
1288
1380
  }
1289
1381
  }
@@ -1302,8 +1394,7 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1302
1394
  // has to report, so it is kept apart from the navigation offset below
1303
1395
  // rather than being overwritten by it.
1304
1396
  const thoughtHit = group.tookThought
1305
- && search !== undefined && matches.has(group.answer)
1306
- && !blockMatchesQuery(state.blocks[group.answer].text, search.query);
1397
+ && search !== undefined && matches.has(group.answer);
1307
1398
  const thoughtRow = offsets.get(group.answer);
1308
1399
  drawnThoughts.set(group.answer, thoughtRow);
1309
1400
  // The answer's own drawn start is its words, never the thought above
@@ -1314,11 +1405,14 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1314
1405
  offsets.set(group.answer, lines.length - bodyRow);
1315
1406
  const answer = state.blocks[group.answer];
1316
1407
  const rendered = cachedBlockLines(answer, options, theme, themeName, depth, toolsExpanded, absorbed.has(group.answer));
1317
- for (const line of paintSearch(rendered, group.answer, occurrences, theme))
1408
+ const thoughtLines = thoughtHit
1409
+ ? groupMemberLines(group.answer, context, true).length + 1 : 0;
1410
+ const answerHits = thoughtLines === 0 ? occurrences : occurrences.map(hit => hit.block === group.answer ? { ...hit, row: hit.row - thoughtLines } : hit);
1411
+ for (const line of paintSearch(rendered, group.answer, answerHits, theme))
1318
1412
  lines.push(line);
1319
1413
  if (thoughtHit && thoughtRow !== undefined)
1320
1414
  blockStarts[group.answer] = bodyRow + thoughtRow;
1321
- if (thoughtHit && thoughtRow !== undefined)
1415
+ if ((thoughtHit || options.selectedProcess === group.answer) && thoughtRow !== undefined)
1322
1416
  offsets.set(group.answer, thoughtRow);
1323
1417
  // One block, two rows: the run painted its thought above, this painted
1324
1418
  // its words below. A search hit can be in either, and the offset has to
@@ -1340,8 +1434,9 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1340
1434
  offsets.set(at, lines.length - bodyRow);
1341
1435
  const rows = !group.live && !opened && trailing?.kind === 'tool'
1342
1436
  ? toolRowLine(trailing, theme, options.width, spinnerFrame)
1343
- : groupMemberLines(at, context, false, 0, group.live || opened);
1344
- filesAt(at, lines.length, rows.length);
1437
+ : groupMemberLines(at, context);
1438
+ disclosureAt(at, lines.length);
1439
+ regionsAt(at, lines.length, rows.length);
1345
1440
  for (const line of rows)
1346
1441
  lines.push(line);
1347
1442
  }
@@ -1363,7 +1458,7 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1363
1458
  blockDrawStarts[at] = drawn === undefined ? headerRow : bodyRow + drawn;
1364
1459
  }
1365
1460
  if (member.kind === 'tool' && options.expandedTools?.has(member.callId) === true) {
1366
- foldMarks.push({ key: `call:${member.callId}`, row });
1461
+ foldMarks.push({ key: `call:${member.callId}${options.rawTools?.has(member.callId) ? ':raw' : ''}`, row });
1367
1462
  }
1368
1463
  // The effective set, not the host's: a search can open a thought the
1369
1464
  // reader never asked for, and the rows that adds still reshaped. The
@@ -1389,10 +1484,11 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1389
1484
  }
1390
1485
  }
1391
1486
  blockStarts[index] = lines.length;
1487
+ disclosureAt(index, lines.length);
1392
1488
  if (block.kind === 'tool' && options.expandedTools?.has(block.callId) === true) {
1393
- foldMarks.push({ key: `call:${block.callId}`, row: lines.length });
1489
+ foldMarks.push({ key: `call:${block.callId}${options.rawTools?.has(block.callId) ? ':raw' : ''}`, row: lines.length });
1394
1490
  }
1395
- if (block.kind === 'assistant' && options.expandedReasoning?.has(reasoningKey(block)) === true) {
1491
+ if (block.kind === 'assistant' && openedThoughts.has(reasoningKey(block))) {
1396
1492
  foldMarks.push({ key: `thought:${reasoningKey(block)}`, row: lines.length });
1397
1493
  }
1398
1494
  const liveBlock = state.status !== 'idle' && isProcessBlock(block)
@@ -1402,17 +1498,34 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1402
1498
  liveFrom ??= lines.length;
1403
1499
  const expanded = (block.kind === 'workspace' && groups.some((group, at) => group.turn === block.turn && openGroups.has(at))) || matches.has(index) || toolsExpanded
1404
1500
  || (block.kind === 'tool' && options.expandedTools?.has(block.callId) === true);
1405
- const rendered = cachedBlockLines(block, options, theme, themeName, depth, expanded, absorbed.has(index));
1406
- filesAt(index, lines.length, rendered.length, expanded);
1501
+ const rendered = cachedBlockLines(block, context.options, theme, themeName, depth, expanded, absorbed.has(index));
1502
+ if (block.kind === 'commandOutput' && block.text.trimEnd().includes('\n')) {
1503
+ const foldable = /^ [▸▾] /u.test(stripAnsi(rendered[0] ?? ''));
1504
+ disclosures.push({ block: index, row: lines.length, column: 0, width: foldable ? Math.max(0, options.width - 8) : 0,
1505
+ key: `output:${index}`, detailEnd: lines.length + rendered.length,
1506
+ ...(options.width >= 30 ? { actionColumn: options.width - 7 } : {}),
1507
+ });
1508
+ }
1509
+ regionsAt(index, lines.length, rendered.length, expanded);
1407
1510
  lines.push(...paintSearch(rendered, index, occurrences, theme));
1408
1511
  blockDrawStarts[index] = blockStarts[index];
1409
1512
  previous = block;
1410
1513
  }
1411
1514
  // Streaming text and a call settling change rows in place without changing
1412
1515
  // this; opening, folding, or a run settling at turn end do.
1516
+ const selectedTool = disclosures.find(target => target.block === options.selectedProcess && target.group === undefined
1517
+ && state.blocks[target.block]?.kind === 'tool');
1518
+ if (selectedTool !== undefined && options.width >= 20) {
1519
+ selectedTool.actionColumn = options.width - 7;
1520
+ const title = lines[selectedTool.row];
1521
+ const content = sliceCells(title, 0, visibleWidth(stripAnsi(title).trimEnd()));
1522
+ lines[selectedTool.row] = padToWidth(truncateToWidth(content, selectedTool.actionColumn - 1), selectedTool.actionColumn)
1523
+ + theme.fg('accent', '[···]') + ' ';
1524
+ }
1413
1525
  const foldShape = foldMarks.map(mark => mark.key).join('\u0001');
1414
1526
  transcriptBodyCache.set(state.blocks, {
1415
1527
  fileRegions,
1528
+ disclosures,
1416
1529
  mathMode: options.mathMode,
1417
1530
  mermaidMode: options.mermaidMode,
1418
1531
  toolDetailsKey: options.toolDetailsKey,
@@ -1426,6 +1539,7 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1426
1539
  spinnerFrame: animatedSpinnerFrame,
1427
1540
  toolsExpanded,
1428
1541
  expandedTools,
1542
+ rawTools,
1429
1543
  expandedReasoning,
1430
1544
  hideThinking,
1431
1545
  openedGroups,
@@ -1434,13 +1548,14 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1434
1548
  turnSpans: state.turnSpans,
1435
1549
  status: state.status,
1436
1550
  focusBlock: options.focusBlock,
1551
+ selectedProcess: options.selectedProcess,
1437
1552
  foldShape,
1438
1553
  foldMarks,
1439
1554
  liveFrom,
1440
1555
  lines,
1441
1556
  blockStarts,
1442
1557
  });
1443
- return { lines, blockStarts, blockDrawStarts, fileRegions, foldShape, foldMarks, liveFrom, occurrences };
1558
+ return { lines, blockStarts, blockDrawStarts, fileRegions, disclosures, foldShape, foldMarks, liveFrom, occurrences };
1444
1559
  }
1445
1560
  function commandSurfaceName(block) {
1446
1561
  if (block?.kind === 'commandOutput')
@@ -1642,7 +1757,7 @@ export function renderAside(panel, theme, width, available = ASIDE_MAX_ROWS) {
1642
1757
  const detail = panel.detail === undefined || panel.detail === ''
1643
1758
  ? []
1644
1759
  : ['', theme.fg('dim', panel.detail)];
1645
- const hint = ' ' + theme.fg('dim', formatOverlayHint([{ keys: '↑↓', action: 'scroll' }, { keys: '/copy', action: 'copy' }, { keys: 'Esc', action: 'close' }]));
1760
+ const hint = ' ' + theme.fg('dim', formatOverlayHint([{ keys: '↑↓', action: 'scroll' }, { keys: '/copy', action: 'copy' }, { keys: 'Esc', action: 'close' }], 'lowercase', theme.language));
1646
1761
  // Two rules, the question, its blank line, and the hint are always drawn. What
1647
1762
  // is left is the answer's window, so the pane never grows past its cap.
1648
1763
  const cap = Math.max(6, Math.min(ASIDE_MAX_ROWS, available));
@@ -1701,12 +1816,14 @@ function subagentPhaseGlyph(phase, theme, spinnerFrame) {
1701
1816
  return theme.fg('accent', SYMBOL.running);
1702
1817
  return theme.fg('accent', SPINNER[spinnerFrame % SPINNER.length] ?? SYMBOL.running);
1703
1818
  }
1819
+ if (phase === 'stopped')
1820
+ return theme.fg('warning', '■');
1704
1821
  if (phase === 'error')
1705
1822
  return theme.fg('error', SYMBOL.error);
1706
1823
  return theme.fg('success', SYMBOL.success);
1707
1824
  }
1708
1825
  const SUBAGENT_PHASE_LABELS = {
1709
- starting: 'Starting', running: 'Running', waiting: 'Waiting', completed: 'Done', error: 'Failed',
1826
+ starting: 'Starting', running: 'Running', waiting: 'Ready', completed: 'Done', stopped: 'Stopped', error: 'Failed',
1710
1827
  };
1711
1828
  /** How long a child has been (or was) running, as one label. */
1712
1829
  export function formatAgentElapsed(agent, now = Date.now()) {
@@ -1720,46 +1837,63 @@ export function formatAgentElapsed(agent, now = Date.now()) {
1720
1837
  return `${(ms / 1_000).toFixed(ms < 10_000 ? 1 : 0)}s`;
1721
1838
  return `${Math.floor(ms / 60_000)}m${String(Math.floor((ms % 60_000) / 1_000)).padStart(2, '0')}s`;
1722
1839
  }
1723
- function subagentRowText(agent) {
1840
+ function subagentRowText(agent, theme) {
1724
1841
  const indent = agent.depth > 1 ? ' '.repeat(agent.depth - 1) : '';
1725
- return `${indent}${agent.label} · ${SUBAGENT_PHASE_LABELS[agent.phase]}`;
1842
+ return `${indent}${agent.label} · ${theme.text(agent.needsApproval ? 'Needs approval' : SUBAGENT_PHASE_LABELS[agent.phase])}`;
1843
+ }
1844
+ /** Choose one viewport for workflows and agents so neither can crowd out the composer. */
1845
+ export function rosterPreviewStart(roster) {
1846
+ const items = rosterItems(roster);
1847
+ const activeWorkflow = items.findIndex(item => item.kind === 'workflow' && item.run.status === 'open');
1848
+ if (activeWorkflow >= 0)
1849
+ return Math.max(0, Math.min(activeWorkflow, items.length - SUBAGENT_PREVIEW));
1850
+ return Math.max(0, Math.min((roster?.workflows?.length ?? 0) + subagentPreviewStart(roster?.agents ?? []), items.length - SUBAGENT_PREVIEW));
1726
1851
  }
1727
1852
  /** Compact, unframed descendant-subagent tree placed above Todos. */
1728
1853
  export function renderSubagents(roster, theme, width, spinnerFrame = 0, inspectedId, selected) {
1729
1854
  const agents = roster?.agents ?? [];
1730
- if (agents.length === 0 || width <= 0)
1855
+ const items = rosterItems(roster);
1856
+ if (items.length === 0 || width <= 0)
1731
1857
  return [];
1732
1858
  const running = agents.filter(agent => agent.phase === 'running' || agent.phase === 'starting').length;
1733
1859
  const failed = agents.filter(agent => agent.phase === 'error').length;
1734
1860
  const waiting = agents.filter(agent => agent.phase === 'waiting').length;
1735
1861
  const done = agents.filter(agent => agent.phase === 'completed').length;
1862
+ const stopped = agents.filter(agent => agent.phase === 'stopped').length;
1863
+ const attention = agents.filter(agent => agent.needsApproval).length;
1736
1864
  const counts = [
1737
- running === 0 ? undefined : `${running} running`,
1738
- waiting === 0 ? undefined : `${waiting} waiting`,
1739
- done === 0 ? undefined : `${done} done`,
1740
- failed === 0 ? undefined : `${failed} failed`,
1865
+ attention === 0 ? undefined : theme.text('{count} need approval', { count: attention }),
1866
+ stopped === 0 ? undefined : theme.text('{count} stopped', { count: stopped }),
1867
+ running === 0 ? undefined : theme.text('{count} running', { count: running }),
1868
+ waiting === 0 ? undefined : theme.text('{count} ready', { count: waiting }),
1869
+ done === 0 ? undefined : theme.text('{count} done', { count: done }),
1870
+ failed === 0 ? undefined : theme.text('{count} failed', { count: failed }),
1741
1871
  ].filter((part) => part !== undefined);
1742
1872
  const pointing = selected !== undefined;
1743
- const headerBody = theme.bold(theme.fg('accent', 'Agents'))
1873
+ const headerBody = theme.bold(theme.fg('accent', theme.text('Agents')))
1744
1874
  + (counts.length === 0 ? '' : theme.fg('dim', ` · ${counts.join(' · ')}`))
1745
1875
  // Short enough to survive a narrow bar: the counts already use the width.
1746
- + theme.fg('dim', pointing ? ' · ↑↓ · Enter open · Esc' : ' · ↓ select');
1876
+ + theme.fg('dim', pointing ? ' · ' + theme.text('↑↓ · Enter open · Esc') : ' · ' + theme.text('↓ select'));
1747
1877
  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}`}`));
1752
- const start = subagentPreviewStart(agents);
1753
- const end = Math.min(agents.length, start + SUBAGENT_PREVIEW);
1878
+ const start = selected === undefined ? rosterPreviewStart(roster) : Math.max(0, Math.min(selected, items.length - SUBAGENT_PREVIEW));
1879
+ const end = Math.min(items.length, start + SUBAGENT_PREVIEW);
1754
1880
  const rows = [
1755
1881
  ...(start === 0 ? [] : [`… ${start} earlier`]),
1756
- ...agents.slice(start, end),
1757
- ...(end === agents.length ? [] : [`… ${agents.length - end} more`]),
1882
+ ...items.slice(start, end),
1883
+ ...(end === items.length ? [] : [`… ${items.length - end} more`]),
1758
1884
  ];
1759
- const lines = [header, ...workflows, ...rows.map((row, index) => {
1885
+ const lines = [header, ...rows.map((item, index) => {
1760
1886
  const branch = ' ' + theme.fg('dim', index === rows.length - 1 ? '└─' : '├─') + ' ';
1761
- if (typeof row === 'string')
1762
- return branch + theme.fg('dim', row);
1887
+ if (typeof item === 'string')
1888
+ return branch + theme.fg('dim', item);
1889
+ const pointed = selected !== undefined && items[selected]?.key === item.key;
1890
+ if (item.kind === 'workflow') {
1891
+ const run = item.run;
1892
+ const body = (pointed ? SYMBOL.cursor + ' ' : '') + theme.text('Workflow') + ` · ${run.name} · ` + theme.text(workflowStatus(run.status))
1893
+ + (run.phase === undefined ? '' : ` · ${run.phase}`);
1894
+ return branch + theme.fg(run.status === 'completed' ? 'success' : run.status === 'open' ? 'accent' : 'warning', pointed ? theme.bold(body) : body);
1895
+ }
1896
+ const row = item.agent;
1763
1897
  const current = inspectedId === row.id;
1764
1898
  const paint = row.phase === 'completed'
1765
1899
  ? (text) => theme.fg('dim', theme.strikethrough(text))
@@ -1768,9 +1902,8 @@ export function renderSubagents(roster, theme, width, spinnerFrame = 0, inspecte
1768
1902
  : row.phase === 'waiting'
1769
1903
  ? (text) => theme.fg('dim', text)
1770
1904
  : (text) => text;
1771
- const pointed = index === selected;
1772
1905
  const marker = current || pointed ? theme.fg('accent', SYMBOL.cursor) + ' ' : '';
1773
- const body = marker + subagentPhaseGlyph(row.phase, theme, spinnerFrame) + ' ' + paint(subagentRowText(row));
1906
+ const body = marker + subagentPhaseGlyph(row.phase, theme, spinnerFrame) + ' ' + paint(subagentRowText(row, theme));
1774
1907
  return branch + (current || pointed ? theme.bold(body) : body);
1775
1908
  })];
1776
1909
  return lines.map(line => truncateToWidth(line, width));
@@ -1779,9 +1912,12 @@ export function renderSubagents(roster, theme, width, spinnerFrame = 0, inspecte
1779
1912
  export function renderInspectBanner(inspected, theme, width, spinnerFrame = 0) {
1780
1913
  if (inspected === undefined || width <= 0)
1781
1914
  return [];
1782
- const guidance = inspected.writable ? 'Enter to steer · Esc to return' : 'read-only · Esc to return';
1915
+ const guidance = inspected.phase === 'running' || inspected.phase === 'starting'
1916
+ ? theme.text('Ctrl+C stops this agent · Esc to return')
1917
+ : inspected.writable ? theme.text('Esc to return') : theme.text('Read-only · Esc to return');
1783
1918
  const line = ' ' + theme.fg('accent', '←') + ' ' + subagentPhaseGlyph(inspected.phase, theme, spinnerFrame)
1784
1919
  + ' ' + theme.bold(inspected.label)
1920
+ + theme.fg('dim', ' · ' + theme.text(inspected.needsApproval ? 'Needs approval' : SUBAGENT_PHASE_LABELS[inspected.phase]))
1785
1921
  + theme.fg('dim', ` · ${guidance}`);
1786
1922
  return [truncateToWidth(line, width)];
1787
1923
  }
@@ -1813,11 +1949,11 @@ function queuedMessageLabel(message) {
1813
1949
  * failed while the user was reading elsewhere leaves an apparently idle screen.
1814
1950
  * The row is bounded to a single line so it cannot squeeze the transcript.
1815
1951
  */
1816
- export function renderTurnError(text, theme, width) {
1952
+ export function renderTurnError(text, theme, width, canContinue = false) {
1817
1953
  if (text === '' || width <= 0)
1818
1954
  return [];
1819
1955
  const prefix = SYMBOL.error + ' ';
1820
- const hint = ' · Alt+R retry';
1956
+ const hint = canContinue ? ' · ' + theme.text('Alt+R continue') : '';
1821
1957
  // Drop the retry hint before the message itself when the terminal is narrow.
1822
1958
  const suffix = width - 2 - visibleWidth(prefix) - visibleWidth(hint) >= 8 ? hint : '';
1823
1959
  const budget = Math.max(1, width - 2 - visibleWidth(prefix) - visibleWidth(suffix));
@@ -1832,23 +1968,26 @@ export function renderSteering(inbox, theme, width) {
1832
1968
  const messages = inbox.filter(message => message.source.kind === 'user');
1833
1969
  if (messages.length === 0 || width <= 0)
1834
1970
  return [];
1835
- const latest = queuedMessageLabel(messages.at(-1));
1971
+ const latest = queuedMessageLabel(messages[0]);
1836
1972
  const count = messages.length > 1 ? ` · ${messages.length}` : '';
1837
1973
  return [truncateToWidth(' ' + theme.fg('border', '│') + ' '
1838
- + theme.bold(theme.fg('accent', 'Guidance · next step' + count))
1974
+ + theme.bold(theme.fg('accent', theme.text('Steer · next step') + count))
1839
1975
  + theme.fg('dim', ' · ') + theme.fg('text', latest), width)];
1840
1976
  }
1841
1977
  /** Compact, unframed pending-message view placed immediately above the composer. */
1842
- export function renderQueuedSubmissions(submissions, theme, width, inbox = []) {
1978
+ export function renderQueuedSubmissions(submissions, theme, width, inbox = [], editable = true) {
1843
1979
  const labels = [
1844
1980
  ...inbox.filter(message => message.source.kind === 'user').map(queuedMessageLabel),
1845
- ...submissions.map(queuedSubmissionLabel),
1981
+ ...submissions.map(submission => theme.text(submission.recovered ? 'Recovered' : 'Sending') + ' · ' + queuedSubmissionLabel(submission)),
1846
1982
  ];
1847
1983
  if (labels.length === 0 || width <= 0)
1848
1984
  return [];
1849
1985
  const start = Math.max(0, labels.length - QUEUED_SUBMISSION_PREVIEW);
1850
1986
  const hidden = start;
1851
- const queueLabel = theme.bold(theme.fg('accent', 'Queued'));
1987
+ const accepted = inbox.some(message => message.source.kind === 'user');
1988
+ const recovered = submissions.some(submission => submission.recovered);
1989
+ const queueLabel = theme.bold(theme.fg('accent', theme.text(accepted ? 'Queue' : recovered ? 'Recovered' : 'Sending')));
1990
+ const timing = theme.text(recovered ? 'review before sending' : accepted ? 'next turn' : 'not yet accepted');
1852
1991
  const rail = ' ' + theme.fg('border', '│') + ' ';
1853
1992
  const alignAction = (left, action, preserveAction = true) => {
1854
1993
  const leftWidth = visibleWidth(left);
@@ -1861,15 +2000,12 @@ export function renderQueuedSubmissions(submissions, theme, width, inbox = []) {
1861
2000
  const paintedLeft = truncateToWidth(left, width - actionWidth - 2);
1862
2001
  return paintedLeft + ' ' + action;
1863
2002
  };
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';
2003
+ const editSingleHint = !editable ? '' : width >= 72 ? theme.text('↑/Alt+Up recall · Alt+Q manage') : theme.text('Alt+Q manage');
2004
+ const editMultiHint = !editable ? '' : width >= 72 ? theme.text('↑ recall latest · Alt+Q manage') : theme.text('Alt+Q manage');
1866
2005
  if (labels.length === 1) {
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))];
2006
+ return [alignAction(rail + queueLabel + theme.fg('dim', ' · ') + theme.fg('dim', timing + ' · ') + theme.fg('text', accepted ? labels[0] ?? '' : queuedSubmissionLabel(submissions[0])), theme.fg('dim', editSingleHint))];
1871
2007
  }
1872
- const summary = ` · ${labels.length} · follow-up${hidden === 0 ? '' : ` · ${hidden} earlier`}`;
2008
+ const summary = ` · ${labels.length} · ${timing}${hidden === 0 ? '' : ' · ' + theme.text('{count} earlier', { count: hidden })}`;
1873
2009
  const visibleLabels = labels.slice(start);
1874
2010
  const lines = [
1875
2011
  alignAction(rail + queueLabel + theme.fg('dim', summary), theme.fg('dim', editMultiHint), false),
@@ -1886,7 +2022,7 @@ export function renderQueuedSubmissions(submissions, theme, width, inbox = []) {
1886
2022
  * @returns the frame to hand to a renderer.
1887
2023
  */
1888
2024
  export function renderView(state, options) {
1889
- const theme = createTheme(options.colors === true, options.colorDepth ?? 'truecolor', options.themeName ?? 'dark', options.terminal);
2025
+ const theme = createTheme(options.colors === true, options.colorDepth ?? 'truecolor', options.themeName ?? 'dark', options.terminal, options.language, options.compactLinks);
1890
2026
  const width = options.width;
1891
2027
  const height = options.height;
1892
2028
  const appName = options.appName ?? 'omdsh';
@@ -1894,6 +2030,10 @@ export function renderView(state, options) {
1894
2030
  const pwd = options.pwd ?? '';
1895
2031
  const motion = options.motion ?? 'full';
1896
2032
  const spinnerFrame = motion === 'off' ? -1 : options.spinnerFrame ?? 0;
2033
+ if (options.fileBrowser !== undefined) {
2034
+ const browser = renderFileBrowser(options.fileBrowser, theme, width, height);
2035
+ return { ...browser, lines: fitFrame(browser.lines, width), documentRows: null };
2036
+ }
1897
2037
  if (options.trajectory !== undefined) {
1898
2038
  const trajectory = renderTrajectory(options.trajectory, theme, width, height);
1899
2039
  return {
@@ -1914,7 +2054,7 @@ export function renderView(state, options) {
1914
2054
  documentRows: null,
1915
2055
  };
1916
2056
  }
1917
- if (options.promptSelector?.request.presentation === 'plan-review' || options.promptSelector?.request.presentation === 'document') {
2057
+ if (options.promptSelector?.renderDocument === undefined && (options.promptSelector?.request.presentation === 'plan-review' || options.promptSelector?.request.presentation === 'document')) {
1918
2058
  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);
1919
2059
  return {
1920
2060
  ...(review.image === undefined ? {} : { image: review.image }),
@@ -1924,6 +2064,7 @@ export function renderView(state, options) {
1924
2064
  // A full-screen surface drew no transcript body.
1925
2065
  documentRows: null,
1926
2066
  ...(review.document === undefined ? {} : { promptDocument: review.document }),
2067
+ ...(review.actions === undefined ? {} : { promptActions: review.actions }),
1927
2068
  };
1928
2069
  }
1929
2070
  const welcome = renderWelcome({
@@ -1946,19 +2087,17 @@ export function renderView(state, options) {
1946
2087
  const activityLabelText = state.status === 'running'
1947
2088
  ? options.workingLabel ?? DEEP_DRIVING_LABEL
1948
2089
  : state.status === 'compacting'
1949
- ? options.workingLabel ?? 'Compacting'
2090
+ ? options.workingLabel ?? theme.text('Compacting')
1950
2091
  : undefined;
1951
2092
  const working = [];
1952
2093
  const statusBar = resolveStatusBarConfig(options.statusBar, options.statusPreset);
1953
2094
  const placeholder = options.inspected === undefined && state.status === 'idle'
1954
2095
  && options.input === '' && (options.inputImages ?? 0) === 0
1955
2096
  ? 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'
2097
+ ? theme.text('Run /login to connect a model')
2098
+ : (options.sessionControls?.plan?.pending ? !options.sessionControls.plan.active : options.sessionControls?.plan?.active)
2099
+ ? theme.text('Describe your task to generate a plan')
2100
+ : theme.text('Ask anything')
1962
2101
  : null;
1963
2102
  const inlineHint = options.transcriptSearch !== undefined
1964
2103
  ? transcriptSearchHint({
@@ -1969,11 +2108,17 @@ export function renderView(state, options) {
1969
2108
  : options.inspected === undefined
1970
2109
  ? slashInlineHint(options.input, options.inputCursor, options.commands)
1971
2110
  ?? placeholder
1972
- ?? (state.status === 'running' && options.steerKey !== undefined
1973
- ? `Enter: follow-up · ${options.steerKey}: next step` : null)
2111
+ ?? (state.status === 'running' && !options.input.startsWith('!') && options.steerKey !== undefined
2112
+ ? (options.input === '' ? '' : ' ') + (options.busyEnter === 'steer'
2113
+ ? theme.text('Enter: Steer · {key}: Queue', { key: options.queueKey ?? 'Alt+Enter' })
2114
+ : theme.text('Enter: Queue · {key}: Steer', { key: options.steerKey })) : null)
1974
2115
  : options.inspected.writable
1975
- ? slashInlineHint(options.input, options.inputCursor, options.commands) ?? 'Enter to steer · Esc to return'
1976
- : 'Read-only · Esc to return';
2116
+ ? slashInlineHint(options.input, options.inputCursor, options.commands) ?? (state.status === 'running'
2117
+ ? options.busyEnter === 'steer'
2118
+ ? theme.text('Enter: Steer · {key}: Queue', { key: options.queueKey ?? 'Alt+Enter' })
2119
+ : theme.text('Enter: Queue · {key}: Steer', { key: options.steerKey ?? 'Alt+S' })
2120
+ : theme.text('Enter to send'))
2121
+ : theme.text('Read-only · Esc to return');
1977
2122
  const statusFooter = renderStatusFooter({
1978
2123
  model: options.model,
1979
2124
  ...(options.reasoningEffort === undefined ? {} : { reasoningEffort: options.reasoningEffort }),
@@ -1987,6 +2132,15 @@ export function renderView(state, options) {
1987
2132
  width,
1988
2133
  }, theme);
1989
2134
  const permissionBadge = renderPermissionBadge(options.sessionControls?.permission, theme);
2135
+ const selectionLabels = options.selectionActions === undefined ? [] : [
2136
+ { action: 'copy', label: '[' + uiText(options.language ?? 'en', 'Copy') + ']' },
2137
+ ...(options.selectionActions === 'copy' ? [] : [{ action: 'quote', label: '[' + uiText(options.language ?? 'en', 'Quote') + ']' }]),
2138
+ ];
2139
+ // Drop whole buttons on narrow terminals; never expose a clipped click target.
2140
+ while (selectionLabels.length > 0 && visibleWidth(selectionLabels.map(item => item.label).join(' ')) + 4 > width)
2141
+ selectionLabels.pop();
2142
+ const selectionNote = selectionLabels.map(item => item.label).join(' ');
2143
+ const selectionHint = selectionNote + ' · ' + uiText(options.language ?? 'en', 'Esc cancel');
1990
2144
  const editorOpts = {
1991
2145
  width,
1992
2146
  input: options.input,
@@ -1997,7 +2151,7 @@ export function renderView(state, options) {
1997
2151
  // says so the moment the character is typed — before the reader commits to
1998
2152
  // running anything. Green is the one ink that means "this runs on your
1999
2153
  // machine", which is also what a successful tool card uses.
2000
- border: options.input.trimStart().startsWith('!')
2154
+ border: options.inspected === undefined && options.input.trimStart().startsWith('!')
2001
2155
  ? 'shellMode'
2002
2156
  : options.inspected !== undefined && options.inspected.writable !== true
2003
2157
  || state.status === 'idle'
@@ -2008,7 +2162,8 @@ export function renderView(state, options) {
2008
2162
  ? { paintInput: (text, start) => paintComposerInputSlice(options.input, text, start, theme) }
2009
2163
  : {}),
2010
2164
  ...(inlineHint !== null ? { inlineHint } : {}),
2011
- ...(options.transientStatus === undefined ? {} : { note: options.transientStatus }),
2165
+ ...(options.transientStatus !== undefined ? { note: options.transientStatus } : selectionNote === '' ? {}
2166
+ : { note: visibleWidth(selectionHint) + 4 <= width ? selectionHint : selectionNote }),
2012
2167
  ...(activityLabelText === undefined ? {} : {
2013
2168
  activity: {
2014
2169
  frame: motion === 'off' ? SYMBOL.running : SPINNER[Math.max(0, spinnerFrame) % SPINNER.length] ?? SYMBOL.running,
@@ -2029,9 +2184,17 @@ export function renderView(state, options) {
2029
2184
  const treeRows = options.promptSelector === undefined || options.promptSelector.request.presentation !== 'tree'
2030
2185
  ? undefined
2031
2186
  : 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
2187
+ let promptVisibleRows = Math.max(3, Math.min(10, height - working.length - 14));
2188
+ const documentPanel = options.promptSelector?.renderDocument === undefined ? undefined : renderPlanReviewPage(options.promptSelector, theme, width, Math.max(10, Math.min(height - working.length - 4, options.promptSelector.renderDocument(theme, Math.max(1, width - 6)).length + 8)), options.input, options.inputCursor, appName);
2189
+ let promptSelector = documentPanel ?? treeRows ?? (options.promptSelector === undefined
2033
2190
  ? undefined
2034
- : renderPromptSelector(options.promptSelector, theme, width, options.input, options.inputCursor, Math.max(3, Math.min(10, height - working.length - 14))));
2191
+ : renderPromptSelector(options.promptSelector, theme, width, options.input, options.inputCursor, promptVisibleRows));
2192
+ // Details and live notices consume rows too. Window choices before they push
2193
+ // the navigation hints and fixed footer outside a short terminal.
2194
+ while (documentPanel === undefined && treeRows === undefined && options.promptSelector?.request.allowCustom === false
2195
+ && promptSelector !== undefined && promptSelector.lines.length > height - working.length - 3 && promptVisibleRows > 3) {
2196
+ promptSelector = renderPromptSelector(options.promptSelector, theme, width, options.input, options.inputCursor, --promptVisibleRows);
2197
+ }
2035
2198
  const settings = promptSelector !== undefined || options.settings === undefined
2036
2199
  ? undefined
2037
2200
  : renderSettings(options.settings, theme, width);
@@ -2041,17 +2204,17 @@ export function renderView(state, options) {
2041
2204
  const editor = promptSelector === undefined && settings === undefined && search === undefined
2042
2205
  ? renderEditor(editorOpts, theme)
2043
2206
  : undefined;
2044
- const queuedSubmissions = editor === undefined || options.inspected !== undefined
2207
+ const queuedSubmissions = editor === undefined
2045
2208
  ? []
2046
2209
  : [
2047
2210
  ...renderSteering(state.nextStepInbox, theme, width),
2048
- ...renderQueuedSubmissions(options.queuedSubmissions ?? [], theme, width, state.nextTurnInbox),
2211
+ ...renderQueuedSubmissions(options.inspected === undefined || options.queuedSubmissionScope === options.inspected.id ? options.queuedSubmissions ?? [] : [], theme, width, state.nextTurnInbox, options.inspected === undefined || options.inspected.writable === true),
2049
2212
  ];
2050
2213
  // Above the composer and below the queue: the failure explains why the queue
2051
2214
  // is not moving, and the row survives until the next submission.
2052
2215
  const turnError = editor === undefined || options.inspected !== undefined || state.turnError === undefined
2053
2216
  ? []
2054
- : renderTurnError(state.turnError, theme, width);
2217
+ : renderTurnError(state.turnError, theme, width, options.canContinue === true);
2055
2218
  const todos = editor === undefined || options.inspected !== undefined ? [] : renderTodos(state.todos, theme, width);
2056
2219
  const aside = editor === undefined || options.inspected !== undefined || options.aside === undefined
2057
2220
  ? []
@@ -2082,9 +2245,12 @@ export function renderView(state, options) {
2082
2245
  // share its header's row, so the offset list can be shorter than the block
2083
2246
  // list, and clamping to it would aim the reader at a different block.
2084
2247
  : Math.max(0, Math.min(options.focusBlock, state.blocks.length - 1));
2248
+ const focusRow = focusIndex === undefined ? undefined : transcript.blockStarts[focusIndex];
2249
+ const detailEnd = focusIndex === undefined ? undefined : transcript.disclosures.find(target => target.block === focusIndex)?.detailEnd;
2250
+ const focusOffset = Math.max(0, Math.min(options.focusBlockOffset ?? 0, (detailEnd ?? 0) - (focusRow ?? 0) - 1));
2085
2251
  const focusStart = focusIndex === undefined ? undefined : options.focusBlockEdge === 'end'
2086
2252
  ? Math.max(0, (transcript.blockStarts[focusIndex + 1] ?? transcript.lines.length) - Math.max(1, budget - 2))
2087
- : transcript.blockStarts[focusIndex];
2253
+ : focusRow === undefined ? undefined : focusRow + focusOffset;
2088
2254
  const requestedStart = focusStart === undefined
2089
2255
  ? (options.scrollStart ?? Number.POSITIVE_INFINITY)
2090
2256
  : transcriptStart + focusStart;
@@ -2102,12 +2268,12 @@ export function renderView(state, options) {
2102
2268
  const lines = [...visible];
2103
2269
  const showJump = !hasOverlay && windowed !== undefined && budget >= 3
2104
2270
  && (windowed.hiddenBelow > 0 || (options.openedGroups?.size ?? 0) > 0 || options.toolsExpanded === true);
2105
- const jumpText = truncateToWidth(width >= 38 ? ' ↓ Jump to latest message · End ' : ' ↓ Latest · End ', width);
2271
+ const jumpText = truncateToWidth('↓ Latest · End', width);
2106
2272
  const jumpColumn = Math.max(0, Math.floor((width - visibleWidth(jumpText)) / 2));
2107
2273
  const jumpToLatest = showJump ? { row: lines.length, column: jumpColumn, width: visibleWidth(jumpText) } : undefined;
2108
2274
  if (visible.length > 0)
2109
2275
  lines.push(showJump
2110
- ? ' '.repeat(jumpColumn) + theme.inverse(jumpText) : '');
2276
+ ? ' '.repeat(jumpColumn) + theme.fg('muted', jumpText) : '');
2111
2277
  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;
2112
2278
  const fill = Math.max(0, height - lines.length - bottomRows);
2113
2279
  /**
@@ -2203,21 +2369,51 @@ export function renderView(state, options) {
2203
2369
  });
2204
2370
  }
2205
2371
  }
2372
+ // Tool context takes precedence in the existing single pinned row. Only
2373
+ // painted detail spans participate; folded calls and following replies do not.
2374
+ if (!hasOverlay && budget >= 3)
2375
+ for (const target of transcript.disclosures) {
2376
+ if (target.detailEnd === undefined)
2377
+ continue;
2378
+ const block = state.blocks[target.block];
2379
+ if (block?.kind !== 'tool')
2380
+ continue;
2381
+ const title = transcript.lines[target.row];
2382
+ const titleStart = block.parentCallId === undefined ? 4 : 6;
2383
+ const label = sliceCells(title, titleStart, (target.actionColumn ?? width) - titleStart).trimEnd();
2384
+ stickyHeaders.push({
2385
+ start: transcriptStart + topPad + target.row + 1,
2386
+ end: transcriptStart + topPad + target.detailEnd,
2387
+ text: paintSurface(theme, 'toolAltBg', 'toolOutput', padToWidth(truncateToWidth(' ' + label, width), width)),
2388
+ });
2389
+ }
2390
+ const selectionActions = [];
2391
+ if (!hasOverlay && editor !== undefined && options.transientStatus === undefined) {
2392
+ const row = editorStart + inputLines.length - 1 + topPad;
2393
+ const painted = stripAnsi(trimmed[row] ?? '');
2394
+ for (const item of selectionLabels) {
2395
+ const at = painted.indexOf(item.label);
2396
+ if (at >= 0)
2397
+ selectionActions.push({ row, column: visibleWidth(painted.slice(0, at)), width: visibleWidth(item.label), action: item.action });
2398
+ }
2399
+ }
2206
2400
  return {
2207
2401
  lines: trimmed,
2402
+ selectionActions,
2208
2403
  stickyHeaders,
2209
2404
  ...(jumpToLatest === undefined ? {} : { jumpToLatest: { ...jumpToLatest, row: jumpToLatest.row + topPad } }),
2210
2405
  // The tree's preview is a scrollable document too: without its coordinates
2211
2406
  // the advertised Ctrl+↑↓ had nothing to move, because the card path never
2212
2407
  // reported them.
2213
2408
  ...(promptSelector?.document === undefined ? {} : { promptDocument: promptSelector.document }),
2409
+ ...(promptSelector?.choices === undefined ? {} : { promptChoices: promptSelector.choices.map(choice => ({ ...choice, row: choice.row + editorStart + topPad })) }),
2410
+ ...(promptSelector?.actions === undefined ? {} : { promptActions: promptSelector.actions.map(action => ({ ...action, row: action.row + editorStart + topPad })) }),
2214
2411
  documentRows: bodySource,
2215
2412
  cursor: {
2216
2413
  row: editorStart + caret.row + topPad,
2217
2414
  column: Math.min(caret.column, width),
2218
2415
  },
2219
- cursorVisible: state.status === 'compacting'
2220
- || (options.inspected !== undefined && options.inspected.writable !== true)
2416
+ cursorVisible: (options.selectedProcess !== undefined && !hasOverlay) || (options.inspected !== undefined && options.inspected.writable !== true)
2221
2417
  ? false
2222
2418
  : promptSelector?.cursorVisible ?? (settings === undefined),
2223
2419
  liveStart,
@@ -2235,6 +2431,8 @@ export function renderView(state, options) {
2235
2431
  hiddenBelow: 0,
2236
2432
  }),
2237
2433
  blockStarts: transcript.blockStarts,
2434
+ disclosures: transcript.disclosures.map(target => ({ ...target, row: transcriptStart + topPad + target.row,
2435
+ ...(target.detailEnd === undefined ? {} : { detailEnd: transcriptStart + topPad + target.detailEnd }) })),
2238
2436
  fileRegions: transcript.fileRegions.map(region => ({ ...region, start: transcriptStart + topPad + region.start, end: transcriptStart + topPad + region.end })),
2239
2437
  blockDrawStarts: transcript.blockDrawStarts,
2240
2438
  foldShape: transcript.foldShape,