@agi-fans/dsh-tui 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/lib/CHANGELOG.md +84 -1
  2. package/lib/chrome/box.js +2 -12
  3. package/lib/chrome/box.js.map +1 -1
  4. package/lib/chrome/code-highlight.d.ts +26 -18
  5. package/lib/chrome/code-highlight.js +114 -39
  6. package/lib/chrome/code-highlight.js.map +1 -1
  7. package/lib/chrome/diff-render.d.ts +1 -2
  8. package/lib/chrome/diff-render.js +41 -90
  9. package/lib/chrome/diff-render.js.map +1 -1
  10. package/lib/chrome/main-screen-renderer.d.ts +4 -32
  11. package/lib/chrome/main-screen-renderer.js +63 -15
  12. package/lib/chrome/main-screen-renderer.js.map +1 -1
  13. package/lib/chrome/markdown.js +128 -93
  14. package/lib/chrome/markdown.js.map +1 -1
  15. package/lib/chrome/math.d.ts +4 -0
  16. package/lib/chrome/math.js +340 -0
  17. package/lib/chrome/math.js.map +1 -0
  18. package/lib/chrome/md-style.d.ts +3 -0
  19. package/lib/chrome/md-style.js.map +1 -1
  20. package/lib/chrome/mermaid.d.ts +6 -0
  21. package/lib/chrome/mermaid.js +56 -0
  22. package/lib/chrome/mermaid.js.map +1 -0
  23. package/lib/chrome/renderer.d.ts +3 -0
  24. package/lib/chrome/renderer.js.map +1 -1
  25. package/lib/chrome/status-config.d.ts +5 -0
  26. package/lib/chrome/status-config.js +3 -0
  27. package/lib/chrome/status-config.js.map +1 -1
  28. package/lib/chrome/status-line.d.ts +0 -21
  29. package/lib/chrome/status-line.js +8 -65
  30. package/lib/chrome/status-line.js.map +1 -1
  31. package/lib/chrome/terminal-images.d.ts +31 -0
  32. package/lib/chrome/terminal-images.js +34 -0
  33. package/lib/chrome/terminal-images.js.map +1 -0
  34. package/lib/chrome/theme.d.ts +5 -6
  35. package/lib/chrome/theme.js +199 -109
  36. package/lib/chrome/theme.js.map +1 -1
  37. package/lib/chrome/welcome-tips.js +1 -1
  38. package/lib/chrome/width.d.ts +7 -3
  39. package/lib/chrome/width.js +78 -14
  40. package/lib/chrome/width.js.map +1 -1
  41. package/lib/commands/diff.js +49 -3
  42. package/lib/commands/diff.js.map +1 -1
  43. package/lib/commands/export.js +28 -5
  44. package/lib/commands/export.js.map +1 -1
  45. package/lib/commands/files.d.ts +8 -0
  46. package/lib/commands/files.js +137 -0
  47. package/lib/commands/files.js.map +1 -0
  48. package/lib/commands/plugins.d.ts +8 -0
  49. package/lib/commands/plugins.js +158 -0
  50. package/lib/commands/plugins.js.map +1 -0
  51. package/lib/commands/session-configuration.d.ts +4 -0
  52. package/lib/commands/session-configuration.js +39 -8
  53. package/lib/commands/session-configuration.js.map +1 -1
  54. package/lib/commands/session.js +47 -15
  55. package/lib/commands/session.js.map +1 -1
  56. package/lib/commands/steer.js +2 -2
  57. package/lib/commands/steer.js.map +1 -1
  58. package/lib/commands/terminal.d.ts +7 -0
  59. package/lib/commands/terminal.js +99 -0
  60. package/lib/commands/terminal.js.map +1 -0
  61. package/lib/definition.d.ts +121 -7
  62. package/lib/input/clipboard.d.ts +12 -7
  63. package/lib/input/clipboard.js +79 -20
  64. package/lib/input/clipboard.js.map +1 -1
  65. package/lib/input/composer-files.d.ts +26 -0
  66. package/lib/input/composer-files.js +50 -0
  67. package/lib/input/composer-files.js.map +1 -0
  68. package/lib/input/editor-discovery.d.ts +21 -0
  69. package/lib/input/editor-discovery.js +99 -0
  70. package/lib/input/editor-discovery.js.map +1 -0
  71. package/lib/input/editor.d.ts +26 -1
  72. package/lib/input/editor.js +126 -12
  73. package/lib/input/editor.js.map +1 -1
  74. package/lib/input/external-editor.d.ts +5 -2
  75. package/lib/input/external-editor.js +14 -12
  76. package/lib/input/external-editor.js.map +1 -1
  77. package/lib/input/fuzzy-search.d.ts +9 -0
  78. package/lib/input/fuzzy-search.js +113 -0
  79. package/lib/input/fuzzy-search.js.map +1 -0
  80. package/lib/input/keybindings-config.d.ts +1 -1
  81. package/lib/input/keybindings-config.js +6 -1
  82. package/lib/input/keybindings-config.js.map +1 -1
  83. package/lib/input/keys.d.ts +11 -1
  84. package/lib/input/keys.js +17 -8
  85. package/lib/input/keys.js.map +1 -1
  86. package/lib/input/mouse-policy.d.ts +3 -0
  87. package/lib/input/mouse-policy.js +14 -0
  88. package/lib/input/mouse-policy.js.map +1 -0
  89. package/lib/input/paste-burst.d.ts +16 -0
  90. package/lib/input/paste-burst.js +61 -0
  91. package/lib/input/paste-burst.js.map +1 -0
  92. package/lib/input/text-selection.d.ts +20 -0
  93. package/lib/input/text-selection.js +195 -0
  94. package/lib/input/text-selection.js.map +1 -0
  95. package/lib/runtime/composer-images.d.ts +1 -0
  96. package/lib/runtime/composer-images.js +11 -3
  97. package/lib/runtime/composer-images.js.map +1 -1
  98. package/lib/runtime/file-open.d.ts +2 -0
  99. package/lib/runtime/file-open.js +12 -0
  100. package/lib/runtime/file-open.js.map +1 -0
  101. package/lib/runtime/file-review.d.ts +24 -0
  102. package/lib/runtime/file-review.js +188 -0
  103. package/lib/runtime/file-review.js.map +1 -0
  104. package/lib/runtime/herdr-agent.d.ts +7 -15
  105. package/lib/runtime/herdr-agent.js +151 -79
  106. package/lib/runtime/herdr-agent.js.map +1 -1
  107. package/lib/runtime/hook-settings.d.ts +17 -0
  108. package/lib/runtime/hook-settings.js +57 -0
  109. package/lib/runtime/hook-settings.js.map +1 -0
  110. package/lib/runtime/human-interaction.js +1 -1
  111. package/lib/runtime/human-interaction.js.map +1 -1
  112. package/lib/runtime/image-preview.d.ts +8 -0
  113. package/lib/runtime/image-preview.js +119 -0
  114. package/lib/runtime/image-preview.js.map +1 -0
  115. package/lib/runtime/plain-tui.d.ts +2 -0
  116. package/lib/runtime/plain-tui.js +2 -2
  117. package/lib/runtime/plain-tui.js.map +1 -1
  118. package/lib/runtime/plugin-settings.d.ts +17 -0
  119. package/lib/runtime/plugin-settings.js +154 -0
  120. package/lib/runtime/plugin-settings.js.map +1 -0
  121. package/lib/runtime/provider-local.d.ts +39 -4
  122. package/lib/runtime/provider-local.js +977 -92
  123. package/lib/runtime/provider-local.js.map +1 -1
  124. package/lib/runtime/runner.js +3 -3
  125. package/lib/runtime/runner.js.map +1 -1
  126. package/lib/runtime/session-archive.d.ts +5 -0
  127. package/lib/runtime/session-archive.js +114 -0
  128. package/lib/runtime/session-archive.js.map +1 -0
  129. package/lib/runtime/subagent-model-selection.d.ts +5 -0
  130. package/lib/runtime/subagent-model-selection.js +15 -0
  131. package/lib/runtime/subagent-model-selection.js.map +1 -0
  132. package/lib/runtime/terminal-notifications.d.ts +8 -0
  133. package/lib/runtime/terminal-notifications.js +16 -0
  134. package/lib/runtime/terminal-notifications.js.map +1 -1
  135. package/lib/runtime/terminal-reader.d.ts +11 -0
  136. package/lib/runtime/terminal-reader.js +87 -0
  137. package/lib/runtime/terminal-reader.js.map +1 -0
  138. package/lib/session/composer-drafts.d.ts +20 -0
  139. package/lib/session/composer-drafts.js +103 -0
  140. package/lib/session/composer-drafts.js.map +1 -0
  141. package/lib/session/conversation-turns.d.ts +16 -0
  142. package/lib/session/conversation-turns.js +33 -0
  143. package/lib/session/conversation-turns.js.map +1 -0
  144. package/lib/session/feature-toggles.d.ts +7 -28
  145. package/lib/session/feature-toggles.js +14 -45
  146. package/lib/session/feature-toggles.js.map +1 -1
  147. package/lib/session/fold-policy.d.ts +0 -17
  148. package/lib/session/fold-policy.js +0 -11
  149. package/lib/session/fold-policy.js.map +1 -1
  150. package/lib/session/interaction-adapter.d.ts +2 -2
  151. package/lib/session/interaction-adapter.js +156 -25
  152. package/lib/session/interaction-adapter.js.map +1 -1
  153. package/lib/session/message-queue.d.ts +22 -0
  154. package/lib/session/message-queue.js +157 -0
  155. package/lib/session/message-queue.js.map +1 -0
  156. package/lib/session/project-file-search.js +3 -21
  157. package/lib/session/project-file-search.js.map +1 -1
  158. package/lib/session/session-controller.d.ts +11 -18
  159. package/lib/session/session-controller.js +195 -87
  160. package/lib/session/session-controller.js.map +1 -1
  161. package/lib/session/session-library.d.ts +11 -3
  162. package/lib/session/session-library.js +48 -8
  163. package/lib/session/session-library.js.map +1 -1
  164. package/lib/session/session-tree.d.ts +26 -0
  165. package/lib/session/session-tree.js +112 -0
  166. package/lib/session/session-tree.js.map +1 -0
  167. package/lib/session/steering.d.ts +3 -0
  168. package/lib/session/steering.js +9 -0
  169. package/lib/session/steering.js.map +1 -0
  170. package/lib/session/tui-settings.d.ts +17 -0
  171. package/lib/session/tui-settings.js +12 -1
  172. package/lib/session/tui-settings.js.map +1 -1
  173. package/lib/session/workflow-projection.d.ts +9 -0
  174. package/lib/session/workflow-projection.js +44 -0
  175. package/lib/session/workflow-projection.js.map +1 -0
  176. package/lib/views/agent-hub.js +9 -4
  177. package/lib/views/agent-hub.js.map +1 -1
  178. package/lib/views/autocomplete.js +5 -25
  179. package/lib/views/autocomplete.js.map +1 -1
  180. package/lib/views/document-reader.d.ts +34 -0
  181. package/lib/views/document-reader.js +145 -0
  182. package/lib/views/document-reader.js.map +1 -0
  183. package/lib/views/event-views.js +39 -2
  184. package/lib/views/event-views.js.map +1 -1
  185. package/lib/views/hotkeys.js +13 -5
  186. package/lib/views/hotkeys.js.map +1 -1
  187. package/lib/views/prompt-selector.d.ts +33 -3
  188. package/lib/views/prompt-selector.js +287 -43
  189. package/lib/views/prompt-selector.js.map +1 -1
  190. package/lib/views/prompt-tree.d.ts +11 -0
  191. package/lib/views/prompt-tree.js +74 -0
  192. package/lib/views/prompt-tree.js.map +1 -0
  193. package/lib/views/session-files.d.ts +7 -0
  194. package/lib/views/session-files.js +62 -0
  195. package/lib/views/session-files.js.map +1 -0
  196. package/lib/views/settings-list.d.ts +24 -2
  197. package/lib/views/settings-list.js +115 -27
  198. package/lib/views/settings-list.js.map +1 -1
  199. package/lib/views/transcript-render.d.ts +23 -39
  200. package/lib/views/transcript-render.js +101 -210
  201. package/lib/views/transcript-render.js.map +1 -1
  202. package/lib/views/transcript-types.d.ts +1 -1
  203. package/package.json +77 -42
  204. package/lib/chrome/style.d.ts +0 -22
  205. package/lib/chrome/style.js +0 -23
  206. package/lib/chrome/style.js.map +0 -1
@@ -12,12 +12,12 @@ import { renderCopySelector } from "./copy-selector.js";
12
12
  import { renderSettings } from "./settings-list.js";
13
13
  import { renderTrajectory } from "./trajectory.js";
14
14
  import { renderAgentHub } from "./agent-hub.js";
15
- import { renderPlanReviewPage, renderPromptSelector, renderPromptSelectorPage, } from "./prompt-selector.js";
15
+ import { renderImagePreviewPage, renderPlanReviewPage, renderPromptSelector, renderPromptSelectorPage, renderPromptTreePage, } from "./prompt-selector.js";
16
16
  import { resolveStatusBarConfig } from "../chrome/status-config.js";
17
17
  import { renderPermissionBadge, renderStatusFooter } from "../chrome/status-line.js";
18
- import { createTheme, SPINNER, SYMBOL, BOX } from "../chrome/theme.js";
18
+ import { createTheme, paintSurface, SPINNER, SYMBOL, BOX } from "../chrome/theme.js";
19
19
  import { renderGoalBar } from "../chrome/goal-bar.js";
20
- import { charWidth, padToWidth, stripAnsi, truncateToWidth, visibleWidth, wrapText } from "../chrome/width.js";
20
+ import { padToWidth, stripAnsi, truncateToWidth, visibleWidth, wrapText } from "../chrome/width.js";
21
21
  import { alignFileDiffs, wrapPaintedDiffRows, } from "../chrome/diff-render.js";
22
22
  import { workspaceBlockLines } from "./workspace-changes.js";
23
23
  import { renderTool, toolArgSubject } from "../chrome/tool-renderers.js";
@@ -26,7 +26,6 @@ import { renderToolsPanel } from "../chrome/tools-list.js";
26
26
  import { renderCommandOutput, renderCommandSeparator } from "../chrome/command-output.js";
27
27
  import { renderPathMentionRows } from "../chrome/path-mentions.js";
28
28
  import { blockMatchesQuery, transcriptSearchHint } from "./transcript-search.js";
29
- import { DEFAULT_FOLD_DENSITY, foldPolicy, foldPolicyKey } from "../session/fold-policy.js";
30
29
  import { contentToText, prettyArgs, reasoningKey, } from "./transcript-types.js";
31
30
  function isBlockPending(block) {
32
31
  return (block.kind === 'assistant' && block.streaming) || (block.kind === 'tool' && block.status === 'running');
@@ -116,9 +115,9 @@ function userBubble(text, theme, width) {
116
115
  // anything else on the screen.
117
116
  return rows.map((row, index) => {
118
117
  if (row === '')
119
- return theme.colors ? theme.bg('userMessageBg', padToWidth('', width)) : padToWidth('', width);
118
+ return paintSurface(theme, 'userMessageBg', 'userMessageText', padToWidth('', width));
120
119
  const content = padToWidth((index === 1 ? gutter : '') + row, width);
121
- return theme.colors ? theme.bg('userMessageBg', content) : content;
120
+ return paintSurface(theme, 'userMessageBg', 'userMessageText', content);
122
121
  });
123
122
  }
124
123
  function toolIcon(status, theme, spinnerFrame) {
@@ -171,7 +170,7 @@ function toolFailureFact(output, command, fact) {
171
170
  * Errors reserve space for their reason; full commands and output stay in the
172
171
  * expanded block. Process rows share an 80-cell measure and right padding.
173
172
  */
174
- function toolRowLine(block, theme, width, spinnerFrame, showSubject = true, indent = '', elapsed) {
173
+ function toolRowLine(block, theme, width, spinnerFrame) {
175
174
  const presentation = renderTool({
176
175
  name: block.name,
177
176
  arguments: prettyArgs(block.args),
@@ -184,7 +183,7 @@ function toolRowLine(block, theme, width, spinnerFrame, showSubject = true, inde
184
183
  // A PTC sub-call ran inside a program, not because the model asked for it
185
184
  // directly. Indenting says that without a second visual vocabulary, and the
186
185
  // width budget shrinks to match so the row still ends at the same column.
187
- const nest = indent + (block.parentCallId === undefined ? '' : ' ');
186
+ const nest = block.parentCallId === undefined ? '' : ' ';
188
187
  // No gutter column. A settled successful call carries no mark at all — a
189
188
  // column of identical bullets says only that the row above exists — and the
190
189
  // two that remain are the states a reader has to act on: motion while the
@@ -193,19 +192,18 @@ function toolRowLine(block, theme, width, spinnerFrame, showSubject = true, inde
193
192
  const head = nest + mark;
194
193
  const room = processRoom(width, head);
195
194
  const label = processLabel(block.name);
196
- const detail = toolRowSubject(block, presentation, label);
197
- const subject = showSubject ? detail : '';
195
+ const subject = toolRowSubject(block, presentation, label);
198
196
  const args = toolArgsObject(block.args);
199
197
  const call = block.presentation?.call;
200
198
  const description = args?.['description'] ?? (call?.card === 'terminal' ? call.description : undefined);
201
- const descriptionLed = showSubject && (COMMAND_TOOL.test(block.name) || call?.card === 'terminal')
199
+ const descriptionLed = (COMMAND_TOOL.test(block.name) || call?.card === 'terminal')
202
200
  && typeof description === 'string' && description.trim() !== '';
203
201
  // The echo a failure skips is the command line, which a description-led row
204
202
  // no longer carries as its subject.
205
- const argCommand = toolArgsObject(block.args)?.['command'];
203
+ const argCommand = args?.['command'];
206
204
  const command = typeof argCommand === 'string'
207
205
  ? argCommand
208
- : block.presentation?.call?.card === 'terminal' ? presentation.input[0] ?? detail : detail;
206
+ : block.presentation?.call?.card === 'terminal' ? presentation.input[0] ?? subject : subject;
209
207
  const fact = block.status === 'error'
210
208
  ? firstLineOf(block.job !== undefined && block.job.status !== 'completed'
211
209
  ? block.job.detail ?? block.job.status
@@ -216,14 +214,9 @@ function toolRowLine(block, theme, width, spinnerFrame, showSubject = true, inde
216
214
  // as a separate column that belonged to no row in particular. It is reserved
217
215
  // first so a long subject cannot push it off; a failure gets the larger
218
216
  // share, because its fact is the reason the reader stopped.
219
- // The running clock is reserved before either of them: a call that has been
220
- // running is what the reader is waiting on, and a subject that pushed its age
221
- // off the row would leave the one live number in the transcript unreported.
222
- const clock = elapsed === undefined || elapsed === '' ? '' : theme.dim(elapsed);
223
- const clockRoom = clock === '' ? 0 : visibleWidth(clock) + ROW_JOIN.length;
224
217
  const factShare = block.status === 'error' ? 0.5 : 1 / 3;
225
- const factRoom = Math.min(visibleWidth(fact), Math.floor(Math.max(1, room - clockRoom) * factShare));
226
- const afterFact = Math.max(1, room - clockRoom - (factRoom === 0 ? 0 : factRoom + ROW_JOIN.length));
218
+ const factRoom = Math.min(visibleWidth(fact), Math.floor(Math.max(1, room) * factShare));
219
+ const afterFact = Math.max(1, room - (factRoom === 0 ? 0 : factRoom + ROW_JOIN.length));
227
220
  const labelRoom = descriptionLed ? 0 : Math.min(visibleWidth(label), afterFact);
228
221
  const detailRoom = descriptionLed ? afterFact : afterFact - labelRoom - ROW_JOIN.length;
229
222
  const labelText = descriptionLed ? '' : truncateToWidth(label, Math.max(1, labelRoom));
@@ -235,9 +228,7 @@ function toolRowLine(block, theme, width, spinnerFrame, showSubject = true, inde
235
228
  // A failure is the one line in a run that has to stay findable while scrolling
236
229
  // past, so its fact carries the error colour rather than the row's dim.
237
230
  const tail = tailText === '' ? '' : block.status === 'error' ? theme.fg('error', tailText) : theme.dim(tailText);
238
- const clockText = clock === '' || text === '' && tailText === '' ? clock
239
- : clock === '' ? '' : ROW_JOIN + clock;
240
- return [padToWidth(truncateToWidth(head + theme.fg('muted', text) + tail + clockText, Math.max(1, width - 2)), width)];
231
+ return [padToWidth(truncateToWidth(head + theme.fg('muted', text) + tail, Math.max(1, width - 2)), width)];
241
232
  }
242
233
  /** Separator between a process subject and its outcome. */
243
234
  const ROW_JOIN = ' · ';
@@ -379,10 +370,8 @@ function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt
379
370
  if (block.job !== undefined)
380
371
  rows.push(...wrapText(theme.fg(block.status === 'error' ? 'error' : 'muted', `Job ${block.job.id} · ${block.job.detail ?? block.job.status}`), room));
381
372
  const bg = block.status === 'running' ? 'toolPendingBg' : block.status === 'error' ? 'toolErrorBg' : 'toolSuccessBg';
382
- return ['', ...rows, ''].map(row => theme.bg(bg, padToWidth(' '.repeat(padding + nest) + row, width)));
373
+ return ['', ...rows, ''].map(row => paintSurface(theme, bg, 'toolOutput', padToWidth(' '.repeat(padding + nest) + row, width)));
383
374
  }
384
- /** How wide a preview may grow before it is cut even at a sentence end. */
385
- const PREVIEW_CELLS = 96;
386
375
  function firstLineOf(text) {
387
376
  for (const line of text.split('\n')) {
388
377
  const trimmed = line.trim();
@@ -391,91 +380,9 @@ function firstLineOf(text) {
391
380
  }
392
381
  return '';
393
382
  }
394
- /**
395
- * The first whole sentence of a line, or the line cut to a readable width.
396
- *
397
- * Taking the first *line* of a thought produced a run-on clause that always ran
398
- * off the right edge and always ended in an ellipsis, so a long turn came back
399
- * as a wall of identically truncated rows. A sentence is the unit a reader can
400
- * hold, and it is usually short enough to fit without a mark saying it was cut.
401
- * Prose that never closes on a sentence falls back to a width, and says so.
402
- */
403
- function firstSentenceOf(line) {
404
- const limit = Math.min(visibleWidth(line), PREVIEW_CELLS);
405
- // The running width is accumulated rather than re-measured per character. A
406
- // thought paragraph runs to thousands of characters, and re-measuring the
407
- // prefix on every step made looking for a sentence that is not there
408
- // quadratic — the common case, since most thoughts do not close on one.
409
- let widthSoFar = 0;
410
- for (let at = 1; at < line.length; at += 1) {
411
- const char = line[at];
412
- const next = line[at + 1];
413
- widthSoFar += charWidth(line.charCodeAt(at));
414
- const wide = char === '\u3002' || char === '\uff01' || char === '\uff1f';
415
- const closes = wide || char === '.' || char === '!' || char === '?';
416
- // A full stop inside an ellipsis, a version tag or a decimal is not an end.
417
- // A CJK stop is never followed by a space, so it ends wherever it stands.
418
- const endsHere = closes && (wide || next === undefined || SENTENCE_FOLLOWER.test(next));
419
- if (!endsHere || widthSoFar > limit)
420
- continue;
421
- // A sentence that closes inside a quotation takes the closing mark with it;
422
- // cutting before it leaves the row with an opening quote and no partner.
423
- let end = at + 1;
424
- while (end < line.length && CLOSING_MARK.test(line[end]))
425
- end += 1;
426
- return line.slice(0, end).trimEnd();
427
- }
428
- return truncateToWidth(line, Math.max(1, limit));
429
- }
430
- const SENTENCE_FOLLOWER = /[\s"'\u201d\u2019)\]\u300d\u300f]/u;
431
- const CLOSING_MARK = /["'\u201d\u2019)\]\u300d\u300f]/u;
432
- /**
433
- * Sentences that carry no content on their own.
434
- *
435
- * A model opens a thought with an acknowledgement — `Good.`, `Hmm.`, `Now,` —
436
- * far more often than a writer would, and a preview that stops there reads as
437
- * a row of noise down the run. Skipping them costs nothing: the next sentence
438
- * is the one the acknowledgement was introducing.
439
- */
440
- const FILLER_SENTENCE = /^(good|great|ok|okay|hmm+|now|so|right|alright|yes|interesting|perfect|done|nice)[\s.!,:;\u2014-]*$/iu;
441
- /** The first sentence of a line that says something, skipping acknowledgements. */
442
- function firstTellingSentenceOf(line) {
443
- let rest = line;
444
- for (let hop = 0; hop < 3; hop += 1) {
445
- const sentence = firstSentenceOf(rest);
446
- const after = rest.slice(sentence.length).trim();
447
- if (!FILLER_SENTENCE.test(sentence) || after === '')
448
- return sentence;
449
- rest = after;
450
- }
451
- return firstSentenceOf(rest);
452
- }
453
- /**
454
- * One-line preview of a reasoning run.
455
- *
456
- * Slicing the raw text would show half a sentence and then rewrite it under the
457
- * reader's eyes on the next token. A paragraph is the unit the model actually
458
- * finishes, so the preview advances to the first line of the last *completed*
459
- * paragraph and waits there until another one closes; before any paragraph has
460
- * closed there is nothing safe to show, and the row says so. Once the block
461
- * settles there is no incomplete tail to avoid, so the whole text's first line
462
- * is used. Inline markers are stripped because the row is prose, not rendered
463
- * markdown: leaving them reads as stray punctuation in a plain line.
464
- */
465
- export function reasoningPreview(text, streaming) {
466
- const clean = text.replaceAll('**', '').replaceAll('`', '').trim();
467
- if (clean === '')
468
- return '';
469
- if (!streaming)
470
- return firstTellingSentenceOf(firstLineOf(clean));
471
- const paragraphs = clean.split(/\n[ \t]*\n/u).map(paragraph => paragraph.trim()).filter(paragraph => paragraph !== '');
472
- if (paragraphs.length < 2)
473
- return '';
474
- return firstTellingSentenceOf(firstLineOf(paragraphs[paragraphs.length - 2] ?? ''));
475
- }
476
383
  /** Thinking uses the same quiet Markdown style throughout the turn. */
477
- function openReasoningLines(block, theme, width) {
478
- return assistantMarkdown(block.reasoning, theme, width, { color: 'thinkingText', italic: true });
384
+ function openReasoningLines(block, theme, width, mathMode = 'auto') {
385
+ return assistantMarkdown(block.reasoning, theme, width, { color: 'thinkingText', italic: true, mathMode });
479
386
  }
480
387
  /** The block indexes a run shows as rows, in order: its members, then the thought it took from its answer. */
481
388
  function groupMembers(group) {
@@ -498,15 +405,15 @@ function groupMemberLines(index, context, thoughtOnly = false) {
498
405
  const block = blocks[index];
499
406
  // Search must reveal a match even when it falls outside a tool's normal preview.
500
407
  const full = block.kind === 'tool' && (context.matches.has(index) || options.expandedTools?.has(block.callId) === true);
501
- const signature = [width, options.colors, context.trueColor, context.themeName, thoughtOnly, full, options.toolDetailsKey].join('\0');
408
+ const signature = [width, options.colors, context.trueColor, context.themeName, options.mathMode, options.mermaidMode, thoughtOnly, full, options.toolDetailsKey].join('\0');
502
409
  const cached = groupMemberCache.get(block);
503
410
  let lines;
504
411
  if (cached?.signature === signature)
505
412
  lines = cached.lines;
506
413
  else {
507
414
  lines = block.kind === 'tool' ? toolBlockLines(block, theme, width, full ? 'details' : 'preview', options.toolDetailsKey)
508
- : block.kind === 'assistant' ? thoughtOnly ? openReasoningLines(block, theme, width)
509
- : blockLines(block, theme, width, 0, false, true)
415
+ : block.kind === 'assistant' ? thoughtOnly ? openReasoningLines(block, theme, width, options.mathMode)
416
+ : blockLines(block, theme, width, { mathMode: options.mathMode ?? 'auto', mermaidMode: options.mermaidMode ?? 'auto' })
510
417
  : block.kind === 'notice' ? blockLines(block, theme, width) : [];
511
418
  groupMemberCache.set(block, { signature, lines });
512
419
  }
@@ -553,24 +460,28 @@ export function formatElapsed(ms) {
553
460
  return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
554
461
  }
555
462
  /** A completed turn's duration and any failures remain visible when folded. */
556
- function processGroupHeader(group, theme, width, elapsed) {
463
+ function processGroupHeader(group, theme, width, elapsed, expandKey = 'Ctrl+O') {
557
464
  const label = elapsed === undefined ? 'Worked' : `Worked for ${elapsed}`;
558
465
  const failure = group.failures === 0 ? '' : theme.fg('error', ` · ${group.failures} failed`);
559
466
  const labelRoom = Math.max(1, width - 4 - visibleWidth(failure));
560
- return [padToWidth(truncateToWidth(`${SYMBOL.folded} ${theme.fg('dim', truncateToWidth(label, labelRoom))}${failure}`, Math.max(1, width - 2)), width)];
467
+ const summary = `${SYMBOL.folded} ${theme.fg('dim', truncateToWidth(label, labelRoom))}${failure}`;
468
+ const hint = ` · ${expandKey} to expand`;
469
+ const suffix = expandKey !== 'Disabled' && visibleWidth(summary) + visibleWidth(hint) <= width - 2
470
+ ? theme.fg('dim', hint) : '';
471
+ return [padToWidth(truncateToWidth(summary + suffix, Math.max(1, width - 2)), width)];
561
472
  }
562
473
  /**
563
474
  * Render a block for the transcript or plain printer. Full output is available
564
- * to direct callers; turn inspection uses previews. Legacy positional options
565
- * remain accepted, and omitReasoning excludes thinking already drawn by a run.
475
+ * to direct callers; turn inspection uses previews. omitReasoning excludes
476
+ * thinking already drawn by a run.
566
477
  */
567
- export function blockLines(block, theme, width, _spinnerFrame = 0, toolsExpanded = false, _reasoningExpanded = false, _toolSubject = true, omitReasoning = false, toolDetailsKey = 'Alt+O') {
478
+ export function blockLines(block, theme, width, { toolsExpanded = false, omitReasoning = false, toolDetailsKey = 'Alt+O', mathMode = 'auto', mermaidMode = 'auto' } = {}) {
568
479
  if (block.kind === 'user')
569
480
  return userBubble(block.text, theme, width);
570
481
  if (block.kind === 'assistant') {
571
482
  const lines = [];
572
483
  if (block.reasoning !== '' && !omitReasoning) {
573
- lines.push(...openReasoningLines(block, theme, width));
484
+ lines.push(...openReasoningLines(block, theme, width, mathMode));
574
485
  if (block.text !== '')
575
486
  lines.push('');
576
487
  }
@@ -579,7 +490,7 @@ export function blockLines(block, theme, width, _spinnerFrame = 0, toolsExpanded
579
490
  lines.push(...assistantContentLines([theme.fg('dim', '…')], width, paddingX));
580
491
  }
581
492
  else if (block.text !== '') {
582
- lines.push(...assistantMarkdown(block.text, theme, width, hasExplicitTextColor(theme) ? { color: 'text' } : undefined));
493
+ lines.push(...assistantMarkdown(block.text, theme, width, { ...(hasExplicitTextColor(theme) ? { color: 'text' } : {}), mathMode, mermaidMode }));
583
494
  }
584
495
  if (block.interrupted === true) {
585
496
  const paddingX = width > ASSISTANT_PADDING_X * 2 ? ASSISTANT_PADDING_X : 0;
@@ -690,74 +601,71 @@ function fitFrame(lines, width, stablePrefix = 0) {
690
601
  const transcriptBodyCache = new WeakMap();
691
602
  /** Settled immutable blocks keep their expensive Markdown/tool layout. */
692
603
  const blockLinesCache = new WeakMap();
693
- function cachedBlockLines(block, options, theme, themeName, trueColor, spinnerFrame, expanded, reasoningExpanded, toolSubject, omitReasoning = false) {
694
- const animatedSpinnerFrame = block.kind === 'tool' && block.status === 'running' ? spinnerFrame : -1;
604
+ function cachedBlockLines(block, options, theme, themeName, trueColor, expanded, omitReasoning = false) {
695
605
  const cached = blockLinesCache.get(block);
696
606
  if (cached !== undefined
697
607
  && cached.width === options.width
698
608
  && cached.colors === options.colors
699
609
  && cached.trueColor === trueColor
700
610
  && cached.themeName === themeName
701
- && cached.spinnerFrame === animatedSpinnerFrame
702
611
  && cached.expanded === expanded
703
- && cached.reasoningExpanded === reasoningExpanded
704
- && cached.toolSubject === toolSubject
705
612
  && cached.omitReasoning === omitReasoning
613
+ && cached.mathMode === options.mathMode
614
+ && cached.mermaidMode === options.mermaidMode
706
615
  && cached.toolDetailsKey === options.toolDetailsKey
707
616
  && cached.toolDetails === (block.kind === 'tool' && options.expandedTools?.has(block.callId) === true))
708
617
  return cached.lines;
709
618
  const toolDetails = block.kind === 'tool' && options.expandedTools?.has(block.callId) === true;
710
619
  const lines = block.kind === 'tool' && toolDetails
711
620
  ? toolBlockLines(block, theme, options.width, 'details', options.toolDetailsKey)
712
- : blockLines(block, theme, options.width, spinnerFrame, expanded, reasoningExpanded, toolSubject, omitReasoning, options.toolDetailsKey);
621
+ : blockLines(block, theme, options.width, { toolsExpanded: expanded, omitReasoning, toolDetailsKey: options.toolDetailsKey ?? 'Alt+O', mathMode: options.mathMode ?? 'auto', mermaidMode: options.mermaidMode ?? 'auto' });
713
622
  blockLinesCache.set(block, {
623
+ mathMode: options.mathMode,
624
+ mermaidMode: options.mermaidMode,
714
625
  toolDetailsKey: options.toolDetailsKey,
715
626
  toolDetails,
716
627
  width: options.width,
717
628
  colors: options.colors,
718
629
  trueColor,
719
630
  themeName,
720
- spinnerFrame: animatedSpinnerFrame,
721
631
  expanded,
722
- reasoningExpanded,
723
- toolSubject,
724
632
  omitReasoning,
725
633
  lines,
726
634
  });
727
635
  return lines;
728
636
  }
729
- /** A phrase that does not inflect reads the same at one and at many. */
730
- const flat = (phrase) => () => phrase;
731
- /** The only inflection a title needs: one call, or more than one. */
732
- const plural = (one, many) => (count) => (count === 1 ? one : many);
733
- const PROCESS_CATEGORIES = [
734
- { match: /^read$/, label: 'Read file', noun: plural('read', 'reads'), phrase: plural('Read a file', 'Read files') },
735
- { match: /^(read_image|readImage)$/, label: 'View image', noun: plural('image', 'images'), phrase: plural('Looked at an image', 'Looked at images') },
736
- { match: /^(write|notebook_edit|apply_patch)$/, label: 'Write file', noun: plural('write', 'writes'), phrase: plural('Wrote a file', 'Wrote files') },
737
- { match: /^(edit|multi_edit|str_replace_editor)$/, label: 'Edit file', noun: plural('edit', 'edits'), phrase: plural('Edited a file', 'Edited files') },
738
- { match: /^(grep|glob|list_dir|search)$/, label: 'Search code', noun: plural('search', 'searches'), phrase: flat('Searched the code') },
739
- { match: /^(bash|shell|pwsh|exec|write_stdin)$/, label: 'Run command', noun: plural('command', 'commands'), phrase: plural('Ran a command', 'Ran commands') },
740
- { match: /^(run_code)$/, label: 'Run code', noun: plural('program', 'programs'), phrase: flat('Ran code') },
741
- { match: /^(web_search)$/, label: 'Search web', noun: plural('web search', 'web searches'), phrase: flat('Searched the web') },
742
- { match: /^(web_fetch)$/, label: 'Fetch page', noun: plural('page', 'pages'), phrase: plural('Visited a page', 'Visited pages') },
637
+ /**
638
+ * What a stretch of the transcript was doing, as a short English label.
639
+ *
640
+ * Status strings are English until a language layer exists, so every label a
641
+ * reader can see is resolved from this one table rather than spelled into the
642
+ * rendering code. A tool with no family is reported by its own name, which is
643
+ * still more informative than dropping it.
644
+ */
645
+ const PROCESS_LABELS = [
646
+ { match: /^read$/, label: 'Read file' },
647
+ { match: /^(read_image|readImage)$/, label: 'View image' },
648
+ { match: /^(write|notebook_edit|apply_patch)$/, label: 'Write file' },
649
+ { match: /^(edit|multi_edit|str_replace_editor)$/, label: 'Edit file' },
650
+ { match: /^(grep|glob|list_dir|search)$/, label: 'Search code' },
651
+ { match: /^(bash|shell|pwsh|exec|write_stdin)$/, label: 'Run command' },
652
+ { match: /^(run_code)$/, label: 'Run code' },
653
+ { match: /^(web_search)$/, label: 'Search web' },
654
+ { match: /^(web_fetch)$/, label: 'Fetch page' },
743
655
  {
744
656
  match: /^(subagent|subagent_fork|subagent_isolated|agent|ralph|workflow_run)$/, label: 'Run subagent',
745
- noun: plural('subagent', 'subagents'),
746
- phrase: plural('Coordinated a subagent', 'Coordinated subagents'),
747
657
  },
748
658
  {
749
659
  match: /^(session_search|session_trace|session_event_read|session_event_search|session_event_trace)$/,
750
- label: 'Search sessions', noun: plural('session search', 'session searches'), phrase: flat('Searched past sessions'),
660
+ label: 'Search sessions',
751
661
  },
752
662
  {
753
663
  match: /^(todo_write|update_goal|create_goal|get_goal)$/, label: 'Update plan',
754
- noun: plural('plan update', 'plan updates'), phrase: flat('Updated the plan'),
755
664
  },
756
665
  {
757
666
  match: /^(ask_user_question|ask_user|user_question)$/, label: 'Ask question',
758
- noun: plural('question', 'questions'), phrase: plural('Asked a question', 'Asked questions'),
759
667
  },
760
- { match: /^skill$/, label: 'Load skill', noun: plural('skill', 'skills'), phrase: plural('Loaded a skill', 'Loaded skills') },
668
+ { match: /^skill$/, label: 'Load skill' },
761
669
  ];
762
670
  /**
763
671
  * The phrase for one tool, or the tool's own name when it belongs to no
@@ -765,32 +673,12 @@ const PROCESS_CATEGORIES = [
765
673
  * category for it would not be.
766
674
  */
767
675
  function processLabel(name) {
768
- for (const category of PROCESS_CATEGORIES) {
676
+ for (const category of PROCESS_LABELS) {
769
677
  if (category.match.test(name))
770
678
  return category.label;
771
679
  }
772
680
  return name;
773
681
  }
774
- function processPhrase(name) {
775
- for (const category of PROCESS_CATEGORIES) {
776
- if (category.match.test(name))
777
- return category.phrase;
778
- }
779
- return () => name;
780
- }
781
- /**
782
- * The bare noun a folded run counts this tool in: `16 commands`.
783
- *
784
- * A tool that belongs to no declared category is counted as a call rather than
785
- * dropped, so the run's totals always add up to what the reader can see.
786
- */
787
- function processNoun(name) {
788
- for (const category of PROCESS_CATEGORIES) {
789
- if (category.match.test(name))
790
- return category.noun;
791
- }
792
- return plural('call', 'calls');
793
- }
794
682
  /** A group has to stand for repetition; one row is not repetition. */
795
683
  const MIN_GROUP_BLOCKS = 2;
796
684
  function blockIdentity(block, index) {
@@ -889,8 +777,6 @@ export function processGroups(blocks) {
889
777
  const size = memberEnd - start + (absorbs === undefined ? 0 : 1);
890
778
  const answerIndex = answer;
891
779
  if (size >= MIN_GROUP_BLOCKS || (size > 0 && segmentTurn !== undefined)) {
892
- const categories = [];
893
- const stats = [];
894
780
  let calls = 0;
895
781
  let failures = 0;
896
782
  let live = false;
@@ -910,18 +796,6 @@ export function processGroups(blocks) {
910
796
  calls += 1;
911
797
  if (block.status === 'error')
912
798
  failures += 1;
913
- const phrase = processPhrase(block.name);
914
- const existing = categories.find(entry => entry.phrase(1) === phrase(1));
915
- if (existing === undefined)
916
- categories.push({ phrase, count: 1 });
917
- else
918
- existing.count += 1;
919
- const noun = processNoun(block.name)(1);
920
- const counted = stats.find(entry => entry.noun === noun);
921
- if (counted === undefined)
922
- stats.push({ noun, count: 1 });
923
- else
924
- counted.count += 1;
925
799
  }
926
800
  const turn = segmentTurn ?? (blocks[end]?.kind === 'workspace' ? blocks[end].turn : undefined);
927
801
  groups.push({
@@ -932,8 +806,6 @@ export function processGroups(blocks) {
932
806
  // painted without it. A separate flag rather than a second index: the
933
807
  // block is always the answer.
934
808
  tookThought: absorbs !== undefined,
935
- categories,
936
- stats,
937
809
  calls,
938
810
  failures,
939
811
  live,
@@ -987,8 +859,6 @@ export function turnGroups(state) {
987
859
  }
988
860
  function renderTranscriptBody(state, options, theme, spinnerFrame) {
989
861
  const toolsExpanded = options.toolsExpanded === true;
990
- const fold = foldPolicy(DEFAULT_FOLD_DENSITY);
991
- const foldKey = foldPolicyKey(fold);
992
862
  const expandedTools = [...(options.expandedTools ?? [])].sort().join('\0');
993
863
  const expandedReasoning = [...(options.expandedReasoning ?? [])].sort().join('\0');
994
864
  const openedGroups = [...(options.openedGroups ?? [])].sort().join('\0');
@@ -1008,13 +878,15 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1008
878
  && cached.trueColor === trueColor
1009
879
  && cached.themeName === themeName
1010
880
  && cached.spinnerFrame === animatedSpinnerFrame
881
+ && cached.mathMode === options.mathMode
882
+ && cached.mermaidMode === options.mermaidMode
1011
883
  && cached.toolsExpanded === toolsExpanded
1012
884
  && cached.expandedTools === expandedTools
1013
885
  && cached.expandedReasoning === expandedReasoning
1014
886
  && cached.openedGroups === openedGroups
1015
887
  && cached.toolDetailsKey === options.toolDetailsKey
1016
- && cached.foldKey === foldKey
1017
888
  && cached.turnSpans === state.turnSpans
889
+ && cached.turnDetailsKey === options.turnDetailsKey
1018
890
  && cached.searchKey === searchKey
1019
891
  && cached.status === state.status
1020
892
  && cached.focusBlock === options.focusBlock) {
@@ -1074,7 +946,6 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1074
946
  openedThoughts.add(reasoningKey(state.blocks[group.answer]));
1075
947
  }
1076
948
  const asked = toolsExpanded
1077
- || !fold.groups
1078
949
  || options.openedGroups?.has(group.key) === true
1079
950
  || state.blocks.slice(group.start, group.until).some(block => block.kind === 'tool' && options.expandedTools?.has(block.callId))
1080
951
  || reasoningHit
@@ -1089,7 +960,6 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1089
960
  const foldMarks = [];
1090
961
  if (toolsExpanded)
1091
962
  foldMarks.push({ key: 'all', row: 0 });
1092
- foldMarks.push({ key: `density:${foldKey}`, row: 0 });
1093
963
  let liveFrom;
1094
964
  const context = {
1095
965
  blocks: state.blocks, theme, themeName, trueColor, width: options.width,
@@ -1098,7 +968,7 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1098
968
  const compactProcessRow = (block) => {
1099
969
  if (block?.kind === 'notice')
1100
970
  return block.process !== undefined && block.framed !== true;
1101
- return block?.kind === 'tool' && fold.tools && !toolsExpanded && !options.expandedTools?.has(block.callId);
971
+ return block?.kind === 'tool' && !toolsExpanded && !options.expandedTools?.has(block.callId);
1102
972
  };
1103
973
  // Only a turn's last run is summarized by how long the turn took; an earlier
1104
974
  // run in the same turn did not take all of it.
@@ -1129,7 +999,7 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1129
999
  liveFrom ??= headerRow;
1130
1000
  // A live run has no header: it becomes a group when its turn ends.
1131
1001
  if (!group.live && !opened) {
1132
- lines.push(...processGroupHeader(group, theme, options.width, elapsedOf(groupIndex)));
1002
+ lines.push(...processGroupHeader(group, theme, options.width, elapsedOf(groupIndex), options.turnDetailsKey));
1133
1003
  }
1134
1004
  const bodyRow = lines.length;
1135
1005
  let offsets = new Map();
@@ -1175,7 +1045,7 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1175
1045
  blockDrawStarts[group.answer] = lines.length;
1176
1046
  offsets.set(group.answer, lines.length - bodyRow);
1177
1047
  const answer = state.blocks[group.answer];
1178
- const rendered = cachedBlockLines(answer, options, theme, themeName, trueColor, spinnerFrame, toolsExpanded || !fold.tools, toolsExpanded || !fold.reasoning, fold.subject, absorbed.has(group.answer));
1048
+ const rendered = cachedBlockLines(answer, options, theme, themeName, trueColor, toolsExpanded, absorbed.has(group.answer));
1179
1049
  for (const line of (search !== undefined && matches.has(group.answer)
1180
1050
  ? rendered.map(row => blockMatchesQuery(stripAnsi(row), search.query) ? theme.inverse(row) : row)
1181
1051
  : rendered))
@@ -1264,12 +1134,8 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1264
1134
  if (liveBlock)
1265
1135
  liveFrom ??= lines.length;
1266
1136
  const expanded = (block.kind === 'workspace' && groups.some((group, at) => group.turn === block.turn && openGroups.has(at))) || matches.has(index) || toolsExpanded
1267
- || !fold.tools
1268
1137
  || (block.kind === 'tool' && options.expandedTools?.has(block.callId) === true);
1269
- const reasoningExpanded = liveBlock || toolsExpanded
1270
- || !fold.reasoning
1271
- || (block.kind === 'assistant' && options.expandedReasoning?.has(reasoningKey(block)) === true);
1272
- const rendered = cachedBlockLines(block, options, theme, themeName, trueColor, spinnerFrame, expanded, reasoningExpanded, fold.subject, absorbed.has(index));
1138
+ const rendered = cachedBlockLines(block, options, theme, themeName, trueColor, expanded, absorbed.has(index));
1273
1139
  lines.push(...(search !== undefined && matches.has(index)
1274
1140
  ? rendered.map(line => blockMatchesQuery(stripAnsi(line), search.query) ? theme.inverse(line) : line)
1275
1141
  : rendered));
@@ -1280,7 +1146,10 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1280
1146
  // this; opening, folding, or a run settling at turn end do.
1281
1147
  const foldShape = foldMarks.map(mark => mark.key).join('\u0001');
1282
1148
  transcriptBodyCache.set(state.blocks, {
1149
+ mathMode: options.mathMode,
1150
+ mermaidMode: options.mermaidMode,
1283
1151
  toolDetailsKey: options.toolDetailsKey,
1152
+ turnDetailsKey: options.turnDetailsKey,
1284
1153
  blockDrawStarts,
1285
1154
  width: options.width,
1286
1155
  colors: options.colors,
@@ -1291,7 +1160,6 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
1291
1160
  expandedTools,
1292
1161
  expandedReasoning,
1293
1162
  openedGroups,
1294
- foldKey,
1295
1163
  searchKey,
1296
1164
  turnSpans: state.turnSpans,
1297
1165
  status: state.status,
@@ -1638,6 +1506,17 @@ export function renderTurnError(text, theme, width) {
1638
1506
  + (suffix === '' ? '' : theme.fg('dim', suffix));
1639
1507
  return [padToWidth(line, width)];
1640
1508
  }
1509
+ /** Human guidance remains visible until the Harness claims it for a model step. */
1510
+ export function renderSteering(inbox, theme, width) {
1511
+ const messages = inbox.filter(message => message.source.kind === 'user');
1512
+ if (messages.length === 0 || width <= 0)
1513
+ return [];
1514
+ const latest = queuedMessageLabel(messages.at(-1));
1515
+ const count = messages.length > 1 ? ` · ${messages.length}` : '';
1516
+ return [truncateToWidth(' ' + theme.fg('border', '│') + ' '
1517
+ + theme.bold(theme.fg('accent', 'Guidance · next step' + count))
1518
+ + theme.fg('dim', ' · ') + theme.fg('text', latest), width)];
1519
+ }
1641
1520
  /** Compact, unframed pending-message view placed immediately above the composer. */
1642
1521
  export function renderQueuedSubmissions(submissions, theme, width, inbox = []) {
1643
1522
  const labels = [
@@ -1662,9 +1541,9 @@ export function renderQueuedSubmissions(submissions, theme, width, inbox = []) {
1662
1541
  return paintedLeft + ' ' + action;
1663
1542
  };
1664
1543
  if (labels.length === 1) {
1665
- return [alignAction(rail + queueLabel + theme.fg('dim', ' · ') + theme.fg('text', labels[0] ?? ''), theme.fg('dim', '↑ edit'))];
1544
+ return [alignAction(rail + queueLabel + theme.fg('dim', ' · ') + theme.fg('dim', 'next turn · ') + theme.fg('text', labels[0] ?? ''), theme.fg('dim', '↑ edit'))];
1666
1545
  }
1667
- const summary = ` · ${labels.length}${hidden === 0 ? '' : ` · ${hidden} earlier`}`;
1546
+ const summary = ` · ${labels.length} · next turn${hidden === 0 ? '' : ` · ${hidden} earlier`}`;
1668
1547
  const visibleLabels = labels.slice(start);
1669
1548
  const lines = [
1670
1549
  alignAction(rail + queueLabel + theme.fg('dim', summary), theme.fg('dim', '↑ edit latest'), false),
@@ -1719,19 +1598,21 @@ export function renderView(state, options) {
1719
1598
  documentRows: null,
1720
1599
  };
1721
1600
  }
1722
- if (options.promptSelector?.request.presentation === 'fullscreen-list') {
1723
- const selector = renderPromptSelectorPage(options.promptSelector, theme, width, height, options.input, options.inputCursor, appName);
1601
+ if (options.promptSelector?.request.presentation === 'fullscreen-list' || options.promptSelector?.request.presentation === 'fullscreen-tree') {
1602
+ const selector = (options.promptSelector.request.presentation === 'fullscreen-tree' ? renderPromptTreePage : renderPromptSelectorPage)(options.promptSelector, theme, width, height, options.input, options.inputCursor, appName);
1724
1603
  return {
1725
1604
  lines: fitFrame(selector.lines, width),
1605
+ ...(selector.document === undefined ? {} : { promptDocument: selector.document }),
1726
1606
  cursor: selector.cursor,
1727
1607
  cursorVisible: true,
1728
1608
  // A full-screen surface drew no transcript body.
1729
1609
  documentRows: null,
1730
1610
  };
1731
1611
  }
1732
- if (options.promptSelector?.request.presentation === 'plan-review') {
1733
- const review = renderPlanReviewPage(options.promptSelector, theme, width, height, options.input, options.inputCursor, appName);
1612
+ if (options.promptSelector?.request.presentation === 'plan-review' || options.promptSelector?.request.presentation === 'document') {
1613
+ const review = options.promptSelector.request.documentImage === undefined ? renderPlanReviewPage(options.promptSelector, theme, width, height, options.input, options.inputCursor, appName) : renderImagePreviewPage(options.promptSelector, theme, width, height, appName, options.imageProtocol, options.imageCellSize);
1734
1614
  return {
1615
+ ...(review.image === undefined ? {} : { image: review.image }),
1735
1616
  lines: fitFrame(review.lines, width),
1736
1617
  cursor: review.cursor,
1737
1618
  cursorVisible: review.cursorVisible === true,
@@ -1772,6 +1653,8 @@ export function renderView(state, options) {
1772
1653
  }, options.transcriptSearch.matches.length)
1773
1654
  : options.inspected === undefined
1774
1655
  ? slashInlineHint(options.input, options.inputCursor, options.commands)
1656
+ ?? (state.status === 'running' && options.steerKey !== undefined
1657
+ ? `Enter: next turn · ${options.steerKey}: next step` : null)
1775
1658
  : options.inspected.writable
1776
1659
  ? slashInlineHint(options.input, options.inputCursor, options.commands) ?? 'Enter to steer · Esc to return'
1777
1660
  : 'Read-only · Esc to return';
@@ -1821,7 +1704,10 @@ export function renderView(state, options) {
1821
1704
  : undefined;
1822
1705
  const queuedSubmissions = editor === undefined || options.inspected !== undefined
1823
1706
  ? []
1824
- : renderQueuedSubmissions(options.queuedSubmissions ?? [], theme, width, state.nextTurnInbox);
1707
+ : [
1708
+ ...renderSteering(state.nextStepInbox, theme, width),
1709
+ ...renderQueuedSubmissions(options.queuedSubmissions ?? [], theme, width, state.nextTurnInbox),
1710
+ ];
1825
1711
  // Above the composer and below the queue: the failure explains why the queue
1826
1712
  // is not moving, and the row survives until the next submission.
1827
1713
  const turnError = editor === undefined || options.inspected !== undefined || state.turnError === undefined
@@ -1831,6 +1717,10 @@ export function renderView(state, options) {
1831
1717
  const goal = editor === undefined || options.inspected !== undefined || options.sessionControls?.goal === undefined
1832
1718
  ? []
1833
1719
  : renderGoalBar(options.sessionControls.goal, theme, width);
1720
+ const pendingCount = options.sessionControls?.pendingQuestions ?? 0;
1721
+ const questions = editor === undefined || options.inspected !== undefined || pendingCount === 0
1722
+ ? []
1723
+ : [truncateToWidth(theme.fg('warning', ` ${pendingCount} pending ${pendingCount === 1 ? 'question' : 'questions'} · /questions to answer`), width)];
1834
1724
  const inspect = editor === undefined ? [] : renderInspectBanner(options.inspected, theme, width, spinnerFrame);
1835
1725
  const subagents = editor === undefined
1836
1726
  ? []
@@ -1842,7 +1732,7 @@ export function renderView(state, options) {
1842
1732
  const inputLines = promptSelector?.lines ?? settings?.lines ?? copySelector?.lines ?? search?.lines
1843
1733
  ?? (editor === undefined ? [] : editor.lines);
1844
1734
  const spacer = 1;
1845
- const reserved = inputLines.length + working.length + inspect.length + subagents.length + todos.length + goal.length + queuedSubmissions.length + turnError.length + spacer + autocomplete.length + statusFooter.length;
1735
+ const reserved = inputLines.length + working.length + inspect.length + subagents.length + todos.length + goal.length + questions.length + queuedSubmissions.length + turnError.length + spacer + autocomplete.length + statusFooter.length;
1846
1736
  const budget = Math.max(0, height - reserved);
1847
1737
  const focusIndex = options.focusBlock === undefined
1848
1738
  ? undefined
@@ -1876,11 +1766,12 @@ export function renderView(state, options) {
1876
1766
  if (visible.length > 0)
1877
1767
  lines.push(showJump
1878
1768
  ? ' '.repeat(jumpColumn) + theme.inverse(jumpText) : '');
1879
- const bottomRows = working.length + goal.length + inspect.length + subagents.length + todos.length + queuedSubmissions.length + turnError.length + inputLines.length + autocomplete.length + statusFooter.length;
1769
+ const bottomRows = working.length + goal.length + questions.length + inspect.length + subagents.length + todos.length + queuedSubmissions.length + turnError.length + inputLines.length + autocomplete.length + statusFooter.length;
1880
1770
  const fill = Math.max(0, height - lines.length - bottomRows);
1881
1771
  lines.push(...Array.from({ length: fill }, () => ''));
1882
1772
  lines.push(...working);
1883
1773
  lines.push(...goal);
1774
+ lines.push(...questions);
1884
1775
  lines.push(...inspect);
1885
1776
  lines.push(...subagents);
1886
1777
  lines.push(...todos);
@@ -1941,7 +1832,7 @@ export function renderView(state, options) {
1941
1832
  stickyHeaders.push({
1942
1833
  start: transcriptStart + (transcript.blockStarts[at + 1] ?? transcript.lines.length),
1943
1834
  end: body.length,
1944
- text: theme.bg('userMessageBg', padToWidth(' '.repeat(padding) + theme.fg('userMessageText', label), width)),
1835
+ text: paintSurface(theme, 'userMessageBg', 'userMessageText', padToWidth(' '.repeat(padding) + label, width)),
1945
1836
  });
1946
1837
  }
1947
1838
  }