@linxiraos/pi-tui 1.2.1 → 1.3.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 (274) hide show
  1. package/CHANGELOG.md +188 -125
  2. package/README.md +36 -0
  3. package/THIRD-PARTY-NOTICES.txt +55 -5
  4. package/dist/types/app-keybindings.d.ts +18 -8
  5. package/dist/types/apps/git/diff-pane.d.ts +5 -0
  6. package/dist/types/apps/git/git-tui.d.ts +16 -2
  7. package/dist/types/apps/git/state.d.ts +11 -0
  8. package/dist/types/autocomplete.d.ts +2 -0
  9. package/dist/types/charts/chart-svg.d.ts +7 -13
  10. package/dist/types/chat/advisor-message.d.ts +4 -1
  11. package/dist/types/chat/assistant-message.d.ts +9 -4
  12. package/dist/types/chat/figure-markdown.d.ts +7 -2
  13. package/dist/types/chat/messages.d.ts +3 -1
  14. package/dist/types/chat/read-tool-group.d.ts +2 -2
  15. package/dist/types/chat/table-chart.d.ts +11 -15
  16. package/dist/types/chat/tool-execution.d.ts +32 -20
  17. package/dist/types/chat/user-message.d.ts +3 -2
  18. package/dist/types/chrome/countdown-timer.d.ts +3 -2
  19. package/dist/types/chrome/diff.d.ts +2 -3
  20. package/dist/types/chrome/transcript-container.d.ts +2 -0
  21. package/dist/types/components/image.d.ts +2 -0
  22. package/dist/types/components/markdown.d.ts +49 -3
  23. package/dist/types/components/text.d.ts +2 -0
  24. package/dist/types/debug-server.d.ts +2 -0
  25. package/dist/types/keybinding-matchers.d.ts +6 -0
  26. package/dist/types/multiplexers/tmux.d.ts +1 -0
  27. package/dist/types/multiplexers/types.d.ts +6 -0
  28. package/dist/types/native/backend.d.ts +5 -4
  29. package/dist/types/native/blobs.d.ts +24 -12
  30. package/dist/types/overlays/agent-hub-projection.d.ts +10 -5
  31. package/dist/types/overlays/annotation-overlay.d.ts +19 -0
  32. package/dist/types/overlays/approval-dialog.d.ts +79 -0
  33. package/dist/types/overlays/ask-dialog.d.ts +11 -6
  34. package/dist/types/overlays/btw-history-panel.d.ts +2 -6
  35. package/dist/types/overlays/hook-editor.d.ts +7 -2
  36. package/dist/types/overlays/hook-input.d.ts +12 -4
  37. package/dist/types/overlays/job-tree.d.ts +50 -0
  38. package/dist/types/overlays/jobs-panel.d.ts +25 -11
  39. package/dist/types/overlays/login-dialog.d.ts +8 -3
  40. package/dist/types/overlays/mcp-add-wizard.d.ts +9 -1
  41. package/dist/types/overlays/model-browser.d.ts +6 -1
  42. package/dist/types/overlays/model-picker.d.ts +2 -0
  43. package/dist/types/overlays/pause-screen.d.ts +5 -0
  44. package/dist/types/overlays/plan-review-overlay.d.ts +4 -0
  45. package/dist/types/overlays/report-panel.d.ts +5 -1
  46. package/dist/types/overlays/reset-usage-selector.d.ts +7 -5
  47. package/dist/types/overlays/session-account-selector.d.ts +4 -0
  48. package/dist/types/overlays/session-info-overlay.d.ts +7 -3
  49. package/dist/types/overlays/session-selector.d.ts +3 -1
  50. package/dist/types/overlays/usage-dashboard.d.ts +6 -6
  51. package/dist/types/prompt/composer.d.ts +21 -20
  52. package/dist/types/prompt/custom-editor.d.ts +20 -10
  53. package/dist/types/prompt/image-references.d.ts +22 -0
  54. package/dist/types/prompt/internal-url-autocomplete.d.ts +1 -0
  55. package/dist/types/prompt/macos-spelling.d.ts +2 -1
  56. package/dist/types/prompt/prose-gate.d.ts +3 -0
  57. package/dist/types/prompt/queued-messages.d.ts +1 -1
  58. package/dist/types/prompt/typing-word.d.ts +25 -0
  59. package/dist/types/prompt/welcome.d.ts +26 -6
  60. package/dist/types/render/hanging-wrap.d.ts +7 -0
  61. package/dist/types/render/hyperlink.d.ts +2 -0
  62. package/dist/types/render/render-utils.d.ts +9 -1
  63. package/dist/types/render/utils.d.ts +1 -1
  64. package/dist/types/setup/scenes/splash.d.ts +6 -5
  65. package/dist/types/status-line/context-usage.d.ts +4 -1
  66. package/dist/types/status-line/host.d.ts +6 -4
  67. package/dist/types/status-line/startup.d.ts +6 -6
  68. package/dist/types/status-line/types.d.ts +3 -0
  69. package/dist/types/stdin-buffer.d.ts +7 -0
  70. package/dist/types/terminal-capabilities.d.ts +16 -1
  71. package/dist/types/terminal-multiplexer.d.ts +7 -0
  72. package/dist/types/terminal.d.ts +8 -4
  73. package/dist/types/theme/symbols.d.ts +1 -1
  74. package/dist/types/theme/theme.d.ts +11 -0
  75. package/dist/types/thinking.d.ts +5 -0
  76. package/dist/types/tmux.d.ts +39 -4
  77. package/dist/types/tools/ast-edit.d.ts +2 -2
  78. package/dist/types/tools/ast-grep.d.ts +2 -2
  79. package/dist/types/tools/bash.d.ts +6 -1
  80. package/dist/types/tools/edit.d.ts +2 -0
  81. package/dist/types/tools/eval.d.ts +53 -4
  82. package/dist/types/tools/find.d.ts +14 -4
  83. package/dist/types/tools/glob.d.ts +3 -3
  84. package/dist/types/tools/grep.d.ts +2 -2
  85. package/dist/types/tools/grouped-file-output.d.ts +9 -4
  86. package/dist/types/tools/native-view.d.ts +1 -1
  87. package/dist/types/tools/read.d.ts +32 -10
  88. package/dist/types/tools/renderer.d.ts +2 -0
  89. package/dist/types/tools/task.d.ts +31 -0
  90. package/dist/types/tools/write.d.ts +2 -0
  91. package/dist/types/tui.d.ts +20 -3
  92. package/package.json +10 -10
  93. package/src/app-keybindings.d.ts +18 -8
  94. package/src/app-keybindings.ts +20 -8
  95. package/src/apps/autoresearch-dashboard.ts +444 -27
  96. package/src/apps/debug/raw-sse.ts +2 -2
  97. package/src/apps/git/diff-pane.ts +39 -41
  98. package/src/apps/git/git-tui.ts +80 -14
  99. package/src/apps/git/sidebar.ts +10 -1
  100. package/src/apps/git/state.ts +12 -0
  101. package/src/autocomplete.d.ts +2 -0
  102. package/src/autocomplete.ts +21 -19
  103. package/src/bracketed-paste.ts +9 -2
  104. package/src/charts/chart-svg.d.ts +7 -13
  105. package/src/charts/chart-svg.ts +19 -22
  106. package/src/chat/advisor-message.d.ts +4 -1
  107. package/src/chat/advisor-message.ts +77 -24
  108. package/src/chat/assistant-message.d.ts +9 -4
  109. package/src/chat/assistant-message.ts +127 -31
  110. package/src/chat/bash-execution.ts +7 -2
  111. package/src/chat/figure-markdown.d.ts +7 -2
  112. package/src/chat/figure-markdown.ts +10 -3
  113. package/src/chat/image-loading.ts +9 -6
  114. package/src/chat/messages.d.ts +3 -1
  115. package/src/chat/messages.ts +4 -1
  116. package/src/chat/read-tool-group.d.ts +2 -2
  117. package/src/chat/read-tool-group.ts +97 -42
  118. package/src/chat/svg-figure.ts +3 -1
  119. package/src/chat/svg-source.ts +6 -3
  120. package/src/chat/table-chart.d.ts +11 -15
  121. package/src/chat/table-chart.ts +24 -57
  122. package/src/chat/tool-execution.d.ts +32 -20
  123. package/src/chat/tool-execution.ts +103 -36
  124. package/src/chat/user-message.d.ts +3 -2
  125. package/src/chat/user-message.ts +54 -34
  126. package/src/chrome/countdown-timer.ts +3 -3
  127. package/src/chrome/diff.d.ts +2 -3
  128. package/src/chrome/diff.ts +4 -9
  129. package/src/chrome/transcript-container.d.ts +2 -0
  130. package/src/chrome/transcript-container.ts +212 -28
  131. package/src/components/editor.ts +12 -12
  132. package/src/components/image.d.ts +2 -0
  133. package/src/components/image.ts +54 -20
  134. package/src/components/markdown.d.ts +49 -3
  135. package/src/components/markdown.ts +461 -11
  136. package/src/components/settings-list.ts +2 -4
  137. package/src/components/text.d.ts +2 -0
  138. package/src/components/text.ts +11 -1
  139. package/src/debug-server.d.ts +2 -0
  140. package/src/debug-server.ts +51 -69
  141. package/src/hotkeys-markdown.ts +2 -0
  142. package/src/keybinding-matchers.d.ts +6 -0
  143. package/src/keybinding-matchers.ts +19 -0
  144. package/src/multiplexers/tmux.d.ts +1 -0
  145. package/src/multiplexers/tmux.ts +3 -0
  146. package/src/multiplexers/types.d.ts +6 -0
  147. package/src/multiplexers/types.ts +6 -0
  148. package/src/native/backend.d.ts +5 -4
  149. package/src/native/backend.ts +13 -5
  150. package/src/native/blobs.d.ts +24 -12
  151. package/src/native/blobs.ts +56 -7
  152. package/src/native/reconcile.ts +43 -21
  153. package/src/overlays/advisor-config.ts +259 -8
  154. package/src/overlays/agent-hub-projection.d.ts +10 -5
  155. package/src/overlays/agent-hub-projection.ts +63 -12
  156. package/src/overlays/annotation-overlay.ts +422 -168
  157. package/src/overlays/approval-dialog.ts +389 -0
  158. package/src/overlays/ask-dialog.ts +244 -105
  159. package/src/overlays/btw-history-panel.ts +208 -147
  160. package/src/overlays/extensions/extension-list.ts +4 -6
  161. package/src/overlays/hook-editor.ts +90 -32
  162. package/src/overlays/hook-input.ts +105 -19
  163. package/src/overlays/job-tree.ts +177 -0
  164. package/src/overlays/jobs-panel.ts +222 -142
  165. package/src/overlays/login-dialog.ts +201 -91
  166. package/src/overlays/mcp-add-wizard.ts +562 -279
  167. package/src/overlays/model-browser.ts +32 -7
  168. package/src/overlays/model-hub.ts +4 -10
  169. package/src/overlays/model-picker.ts +58 -21
  170. package/src/overlays/pause-screen.ts +81 -23
  171. package/src/overlays/plan-review-overlay.ts +16 -0
  172. package/src/overlays/plugin-selector.ts +17 -1
  173. package/src/overlays/queue-mode-selector.ts +10 -0
  174. package/src/overlays/report-panel.ts +6 -2
  175. package/src/overlays/reset-usage-selector.ts +130 -21
  176. package/src/overlays/session-account-selector.ts +36 -0
  177. package/src/overlays/session-info-overlay.ts +199 -76
  178. package/src/overlays/session-selector.ts +15 -10
  179. package/src/overlays/settings-selector.ts +3 -0
  180. package/src/overlays/show-images-selector.ts +1 -0
  181. package/src/overlays/snapcompact-shape-preview.ts +13 -6
  182. package/src/overlays/theme-selector.ts +6 -1
  183. package/src/overlays/thinking-selector.ts +8 -4
  184. package/src/overlays/tree-selector.ts +117 -2
  185. package/src/overlays/usage-dashboard.ts +498 -144
  186. package/src/prompt/composer-cache.ts +1 -1
  187. package/src/prompt/composer.ts +194 -74
  188. package/src/prompt/custom-editor.ts +168 -65
  189. package/src/prompt/image-references.d.ts +22 -0
  190. package/src/prompt/image-references.ts +79 -2
  191. package/src/prompt/internal-url-autocomplete.ts +1 -0
  192. package/src/prompt/macos-spelling.ts +28 -5
  193. package/src/prompt/prose-gate.ts +3 -0
  194. package/src/prompt/queued-messages.ts +9 -2
  195. package/src/prompt/tips.txt +1 -2
  196. package/src/prompt/typing-word.ts +59 -0
  197. package/src/prompt/welcome.ts +97 -45
  198. package/src/render/code-cell.ts +18 -5
  199. package/src/render/hanging-wrap.d.ts +7 -0
  200. package/src/render/hanging-wrap.ts +58 -0
  201. package/src/render/hyperlink.d.ts +2 -0
  202. package/src/render/hyperlink.ts +5 -0
  203. package/src/render/render-utils.d.ts +9 -1
  204. package/src/render/render-utils.ts +16 -2
  205. package/src/render/utils.d.ts +1 -1
  206. package/src/render/utils.ts +1 -1
  207. package/src/render/width-aware-text.ts +6 -5
  208. package/src/setup/scenes/splash.ts +22 -8
  209. package/src/status-line/component.ts +23 -10
  210. package/src/status-line/context-usage.ts +176 -73
  211. package/src/status-line/footer.ts +18 -35
  212. package/src/status-line/host.ts +6 -1
  213. package/src/status-line/segments.ts +16 -5
  214. package/src/status-line/startup.ts +6 -6
  215. package/src/status-line/types.ts +1 -0
  216. package/src/stdin-buffer.d.ts +7 -0
  217. package/src/stdin-buffer.ts +11 -1
  218. package/src/terminal-capabilities.d.ts +16 -1
  219. package/src/terminal-capabilities.ts +66 -28
  220. package/src/terminal-multiplexer.d.ts +7 -0
  221. package/src/terminal-multiplexer.ts +14 -0
  222. package/src/terminal.d.ts +8 -4
  223. package/src/terminal.ts +160 -46
  224. package/src/theme/symbols.d.ts +1 -1
  225. package/src/theme/symbols.ts +1 -0
  226. package/src/theme/theme-class.ts +1 -0
  227. package/src/theme/theme.d.ts +11 -0
  228. package/src/theme/theme.ts +46 -14
  229. package/src/thinking.d.ts +5 -0
  230. package/src/thinking.ts +9 -1
  231. package/src/tmux.d.ts +39 -4
  232. package/src/tmux.ts +110 -16
  233. package/src/tools/ask.ts +20 -9
  234. package/src/tools/ast-edit.d.ts +2 -2
  235. package/src/tools/ast-edit.ts +30 -7
  236. package/src/tools/ast-grep.d.ts +2 -2
  237. package/src/tools/ast-grep.ts +39 -35
  238. package/src/tools/bash.d.ts +6 -1
  239. package/src/tools/bash.ts +15 -9
  240. package/src/tools/debug.ts +2 -1
  241. package/src/tools/default-renderer.ts +3 -2
  242. package/src/tools/edit.d.ts +2 -0
  243. package/src/tools/edit.ts +100 -21
  244. package/src/tools/eval.d.ts +53 -4
  245. package/src/tools/eval.ts +173 -137
  246. package/src/tools/find.d.ts +14 -4
  247. package/src/tools/find.ts +71 -18
  248. package/src/tools/glob.d.ts +3 -3
  249. package/src/tools/glob.ts +51 -17
  250. package/src/tools/grep.d.ts +2 -2
  251. package/src/tools/grep.ts +99 -22
  252. package/src/tools/grouped-file-output.d.ts +9 -4
  253. package/src/tools/grouped-file-output.ts +21 -6
  254. package/src/tools/lsp.ts +30 -18
  255. package/src/tools/mcp.ts +2 -2
  256. package/src/tools/native-view.d.ts +1 -1
  257. package/src/tools/native-view.ts +2 -2
  258. package/src/tools/read.d.ts +32 -10
  259. package/src/tools/read.ts +89 -43
  260. package/src/tools/renderer.d.ts +2 -0
  261. package/src/tools/renderer.ts +2 -0
  262. package/src/tools/task.d.ts +31 -0
  263. package/src/tools/task.ts +40 -7
  264. package/src/tools/todo.ts +3 -2
  265. package/src/tools/wait.ts +16 -30
  266. package/src/tools/web-search.ts +2 -1
  267. package/src/tools/write.d.ts +2 -0
  268. package/src/tools/write.ts +36 -12
  269. package/src/tools/yield.ts +2 -2
  270. package/src/tui.d.ts +20 -3
  271. package/src/tui.ts +140 -36
  272. package/src/utils.ts +5 -3
  273. package/dist/types/tools/eval-format/code-mode-declarations.d.ts +0 -5
  274. package/src/tools/eval-format/code-mode-declarations.ts +0 -61
@@ -4,9 +4,9 @@ import { visibleWidth } from "../utils";
4
4
  import type { AdvisorMessageDetails, AdvisorNote, AdvisorSeverity } from "./messages";
5
5
  import { formatBadge, replaceTabs, type ToolUIColor, wrapTextWithAnsi } from "../render/render-utils";
6
6
  import { Ellipsis, truncateToWidth } from "../render";
7
- import { getThemeEpoch, type Theme } from "../theme";
8
- import type { TspSpan } from "@linxiraos/pi-wire";
9
- import { card, span, text } from "../native/describe";
7
+ import type { Theme } from "../theme";
8
+ import type { TspSpan, TspTone } from "@linxiraos/pi-wire";
9
+ import { card, node, span, text } from "../native/describe";
10
10
  import { type NativeNode, type NativeUiEvent, rootToggleExpanded } from "../native/node";
11
11
  import { plainText } from "../native/spans";
12
12
  import { Memo } from "../native/memo";
@@ -111,13 +111,16 @@ class AdvisorNotes implements Component {
111
111
  }
112
112
  }
113
113
 
114
+ /** The advisor a note came from; undefined for the implicit single ("default") advisor, which goes unlabeled. */
115
+ function sourceOf(entry: AdvisorNote): string | undefined {
116
+ return entry.advisor && entry.advisor !== "default" ? entry.advisor : undefined;
117
+ }
118
+
114
119
  /** Wrapped, truncated rail rows for a single note; shared by both branches. */
115
120
  function renderAdvisorNote(entry: AdvisorNote, width: number, uiTheme: Theme): string[] {
116
121
  const badge = entry.severity ? `${formatBadge(entry.severity, severityColor(entry.severity), uiTheme)} ` : "";
117
- // Multi-advisor: attribute the note to its source. The implicit
118
- // single ("default") advisor renders unlabeled, as before.
119
- const who =
120
- entry.advisor && entry.advisor !== "default" ? `${uiTheme.fg("dim", `[${replaceTabs(entry.advisor)}]`)} ` : "";
122
+ const source = sourceOf(entry);
123
+ const who = source ? `${uiTheme.fg("dim", `[${replaceTabs(source)}]`)} ` : "";
121
124
  const age = entry.turnsAgo !== undefined ? `${uiTheme.fg("dim", `T-${entry.turnsAgo}`)} ` : "";
122
125
  const railGlyph = uiTheme.symbol("advisor.rail");
123
126
  const rail = uiTheme.fg(severityColor(entry.severity), railGlyph);
@@ -145,16 +148,47 @@ function renderAdvisorNote(entry: AdvisorNote, width: number, uiTheme: Theme): s
145
148
  );
146
149
  }
147
150
 
148
- /** Native spans for one note: severity badge, advisor attribution, then the note text. */
149
- function advisorNoteSpans(entry: AdvisorNote): TspSpan[] {
151
+ /** A note's rank in the native callout: its severity, `note` when it has none. */
152
+ type AdvisorRank = AdvisorSeverity | "note";
153
+
154
+ /** Ranks worst first: the callout wears the worst rank among its notes. */
155
+ const RANKS: readonly AdvisorRank[] = ["blocker", "concern", "nit", "note"];
156
+
157
+ /** Each rank's native look, GitHub-alert style: card/icon tone, Tern icon name, label and its span style. */
158
+ const RANK_LOOK: Record<AdvisorRank, { tone: TspTone; icon: string; label: string; style: string }> = {
159
+ blocker: { tone: "error", icon: "octagon-alert", label: "Blocker", style: "error strong" },
160
+ concern: { tone: "warning", icon: "warn", label: "Concern", style: "warning strong" },
161
+ nit: { tone: "neutral", icon: "lightbulb", label: "Nit", style: "strong" },
162
+ note: { tone: "neutral", icon: "info", label: "Note", style: "strong" },
163
+ };
164
+
165
+ function rankOf(entry: AdvisorNote): AdvisorRank {
166
+ return entry.severity ?? "note";
167
+ }
168
+
169
+ /** The callout's title: one note's rank (`Concern`), else a tally worst first (`1 blocker · 2 concerns`). */
170
+ function tallySpans(notes: readonly AdvisorNote[]): TspSpan[] {
171
+ if (notes.length === 1) {
172
+ const { label, style } = RANK_LOOK[rankOf(notes[0]!)];
173
+ return [span(label, style)];
174
+ }
150
175
  const spans: TspSpan[] = [];
151
- if (entry.severity)
152
- spans.push(span(entry.severity.toUpperCase(), `${severityColor(entry.severity)} strong`), span(" "));
153
- if (entry.advisor && entry.advisor !== "default") spans.push(span(`[${entry.advisor}] `, "dim"));
154
- spans.push(span(plainText(entry.note), "customMessageText"));
176
+ for (const rank of RANKS) {
177
+ const count = notes.filter(entry => rankOf(entry) === rank).length;
178
+ if (count === 0) continue;
179
+ if (spans.length > 0) spans.push(span(" · ", "dim"));
180
+ spans.push(span(`${count} ${rank}${count === 1 ? "" : "s"}`, RANK_LOOK[rank].style));
181
+ }
155
182
  return spans;
156
183
  }
157
184
 
185
+ /** Native spans for one note: its advisor when the callout mixes several, then the text. */
186
+ function noteSpans(entry: AdvisorNote, attribute: boolean): TspSpan[] {
187
+ const who = attribute ? sourceOf(entry) : undefined;
188
+ const body = span(plainText(entry.note));
189
+ return who ? [span(`${who} · `, "dim"), body] : [body];
190
+ }
191
+
158
192
  /** The advisor card: shared by the controlled expansion getter and native toggles. */
159
193
  export interface AdvisorMessageCard extends Component {
160
194
  /** Transcript-wide expansion (`Ctrl+O`); clears a toggle made in the terminal. */
@@ -168,6 +202,9 @@ export interface AdvisorMessageCard extends Component {
168
202
  * output (whose `thinkingText` color equals `toolOutput` in most themes):
169
203
  * a bold `customMessageLabel` header tag (skill-card convention), a heavy
170
204
  * rail tinted per-note severity, and the note body on the default text color.
205
+ * Natively it is a callout tinted by its worst note: that severity's icon and
206
+ * the tally head it, the advisor's name ends the head, and with several notes
207
+ * each leads with its own severity icon.
171
208
  *
172
209
  * `getExpanded` owns expansion; a native toggle overrides it for this card
173
210
  * until the next transcript-wide change.
@@ -193,24 +230,40 @@ export function createAdvisorMessageCard(
193
230
  };
194
231
  const nativeMemo = new Memo();
195
232
  const describeCard = (isExpanded: boolean): NativeNode => {
196
- const head: TspSpan[] = [
197
- span(`${uiTheme.status.info} Advisor`, "customMessageLabel strong"),
198
- span(` ${notes.length} ${notes.length === 1 ? "note" : "notes"}`, "dim"),
199
- ];
200
- if (blockers > 0) head.push(span(`${uiTheme.sep.dot}${blockers} blocker${blockers === 1 ? "" : "s"}`, "error"));
201
- const body = notes.map((entry, index) =>
202
- text(advisorNoteSpans(entry), { wrap: "word", role: "zeta.advisor.note", key: `n${index}` }),
233
+ const look = RANK_LOOK[RANKS.find(rank => notes.some(entry => rankOf(entry) === rank)) ?? "note"];
234
+ const sources = new Set(notes.map(sourceOf));
235
+ const [sole] = sources;
236
+ const head = node(
237
+ "row",
238
+ { gap: "sm", align: "center", role: "zeta.advisor.head" },
239
+ [
240
+ node("icon", { name: look.icon, tone: look.tone }),
241
+ text(tallySpans(notes), { grow: 1 }),
242
+ text([span(sources.size === 1 && sole ? sole : "Advisor", "dim")], { role: "zeta.advisor.source" }),
243
+ ],
244
+ "head",
203
245
  );
246
+ const attribute = sources.size > 1;
247
+ const body = notes.map((entry, index) => {
248
+ const spans = noteSpans(entry, attribute);
249
+ if (notes.length === 1) return text(spans, { wrap: "word", role: "zeta.advisor.note", key: `n${index}` });
250
+ const { tone, icon, label } = RANK_LOOK[rankOf(entry)];
251
+ return node(
252
+ "row",
253
+ { gap: "sm", align: "start", role: "zeta.advisor.note", tone },
254
+ [node("icon", { name: icon, tone, aria: label }), text(spans, { wrap: "word", grow: 1, shrink: 1 })],
255
+ `n${index}`,
256
+ );
257
+ });
204
258
  return card(
205
259
  {
206
260
  role: "zeta.advisor",
207
- tone: blockers > 0 ? "error" : "info",
208
- head,
261
+ tone: look.tone,
209
262
  collapsible: notes.length > COLLAPSED_NOTES,
210
263
  collapsed: notes.length > COLLAPSED_NOTES ? !isExpanded : undefined,
211
264
  preview: notes.length > COLLAPSED_NOTES ? "auto" : undefined,
212
265
  },
213
- body,
266
+ [head, ...body],
214
267
  );
215
268
  };
216
269
 
@@ -231,7 +284,7 @@ export function createAdvisorMessageCard(
231
284
  },
232
285
  describe(): NativeNode {
233
286
  const isExpanded = expanded();
234
- return nativeMemo.get([isExpanded, getThemeEpoch()], () => describeCard(isExpanded));
287
+ return nativeMemo.get([isExpanded], () => describeCard(isExpanded));
235
288
  },
236
289
  setExpanded(value: boolean): void {
237
290
  lastGlobal = value;
@@ -2,7 +2,7 @@ import type { AssistantMessage, ImageContent } from "@linxiraos/pi-ai";
2
2
  import { type Component, Container } from "../tui";
3
3
  import { type ImageBudget } from "../components/image";
4
4
  import type { AssistantThinkingRenderer } from "./extension-types";
5
- import type { NativeNode, NativeUiEvent } from "../native/node";
5
+ import type { DescribeContext, NativeNode, NativeUiEvent } from "../native/node";
6
6
  import { type CacheInvalidation } from "./cache-invalidation-marker";
7
7
  import { type ServedModelMismatch } from "./served-model-marker";
8
8
  import { type TranscriptStableRow } from "../chrome/transcript-container";
@@ -99,13 +99,13 @@ export declare class AssistantMessageComponent extends Container {
99
99
  * head's `title`. With `hideThinkingBlock` only the live head shows (collapsed)
100
100
  * and a finished thought emits nothing.
101
101
  */
102
- describe(): NativeNode;
102
+ describe(cx?: DescribeContext): NativeNode;
103
103
  /**
104
104
  * A TTSR rule rewound this partial answer: a native terminal dims it and
105
105
  * tags it `↺ rewound` (the ANSI render leaves it as it stopped).
106
106
  */
107
107
  markRewound(): void;
108
- /** Mirrors a thinking section collapsed or expanded in the terminal. */
108
+ /** Mirrors a thinking section collapsed or expanded in the terminal; runs its controls' clicks. */
109
109
  handleNativeEvent(event: NativeUiEvent): void;
110
110
  /** Width-independent stable identities for the streamed leading thinking run. */
111
111
  getTranscriptStableRows(): readonly TranscriptStableRow[];
@@ -130,7 +130,12 @@ export declare class AssistantMessageComponent extends Container {
130
130
  setTableChartsVisible(visible: boolean): void;
131
131
  /** Toggle only images produced by tool results; assistant-native images remain governed by setImagesVisible. */
132
132
  setToolResultImagesVisible(visible: boolean): void;
133
- setToolResultImages(toolCallId: string, images: ImageContent[]): void;
133
+ /**
134
+ * A grouped read's result images, and `mediaPath`: the file it read that Tern's media viewer
135
+ * shows (read details), which a native host supporting `media` shows by path instead of the
136
+ * images. A media-only result (a model, a PDF) passes no images.
137
+ */
138
+ setToolResultImages(toolCallId: string, images: ImageContent[], mediaPath?: string): void;
134
139
  updateContent(message: AssistantMessage, opts?: {
135
140
  transient?: boolean;
136
141
  }): void;
@@ -1,12 +1,16 @@
1
1
  import type { AssistantMessage, ImageContent, TextContent } from "@linxiraos/pi-ai";
2
+ import { presentNarrationAsAnswer } from "@linxiraos/pi-ai/utils/narration";
2
3
  import { type Component, Container } from "../tui";
3
4
  import { Image, type ImageBudget } from "../components/image";
4
5
  import { ImageProtocol, TERMINAL } from "../terminal-capabilities";
5
6
  import {
6
7
  type DefaultTextStyle,
8
+ holdPartialTableRow,
7
9
  Markdown,
8
10
  type MarkdownTheme,
9
11
  rewriteMarkdownLinkDestinations,
12
+ speculativeStreamingSource,
13
+ streamingMarkdownSource,
10
14
  } from "../components/markdown";
11
15
  import { Spacer } from "../components/spacer";
12
16
  import { Text } from "../components/text";
@@ -17,9 +21,10 @@ import type { AssistantThinkingRenderer } from "./extension-types";
17
21
  import { ensureThemeSync, getMarkdownTheme, getMarkdownThemeWithLinkTargets, getThemeEpoch, theme } from "../theme";
18
22
  import { card, col, elapsed, node, span, text } from "../native/describe";
19
23
  import { hasTranscriptActions, runTranscriptAction } from "./transcript-actions";
20
- import type { NativeChild, NativeNode, NativeUiEvent } from "../native/node";
24
+ import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "../native/node";
21
25
  import { isNativeRendering } from "../native/state";
22
- import { NativeImageCache } from "../native/blobs";
26
+ import { NativeImageCache, nativeMediaNode } from "../native/blobs";
27
+ import { linkedImageText } from "../prompt/image-references";
23
28
  import { Memo } from "../native/memo";
24
29
  import { EMPTY_LINK_TARGETS, resolveImageOptions } from "../render/render-utils";
25
30
  import { WidthAwareText } from "../render";
@@ -35,7 +40,7 @@ import { formatTurnUsage, type TurnUsageSummary } from "../overlays/usage-row";
35
40
  import { FigureMarkdown } from "./figure-markdown";
36
41
  import { svgFigureRendering } from "./svg-figure";
37
42
  import { hasSvgFence } from "./svg-source";
38
- import { describeTableChart, hasChartTable, lookupTableChart, splitTableCharts } from "./table-chart";
43
+ import { chartFence, hasChartTable, lookupTableChart, splitTableCharts } from "./table-chart";
39
44
 
40
45
  /**
41
46
  * Max wrapped rows of a turn-ending provider error rendered inline in the
@@ -111,6 +116,14 @@ interface StableRenderInputs {
111
116
  readonly color: ((text: string) => string) | undefined;
112
117
  }
113
118
 
119
+ /**
120
+ * A partial last thinking line the prose fold may still drop: the start of a
121
+ * fence marker (`` ` ``, `~`) or of an empty `<!-- -->` sentinel. Once it turns
122
+ * into either, the fold's ellipsis lands back on the line above it
123
+ * (`formatThinkingForDisplay`), so that line is not yet settled.
124
+ */
125
+ const UNSETTLED_THINKING_TAIL = /(?:^|\n)[^\S\n]*(?:`{1,2}|~{1,2}|<(?:!(?:-(?:-[^\S\n]*-{0,2})?)?)?)[^\S\n]*$/;
126
+
114
127
  function isSnapshotExtension(previous: readonly StablePart[], current: readonly StablePart[]): boolean {
115
128
  if (previous.length > current.length) return false;
116
129
  for (let index = 0; index < previous.length; index++) {
@@ -250,6 +263,8 @@ export class AssistantMessageComponent extends Container {
250
263
  #lastMessage?: AssistantMessage;
251
264
  #emergencyText?: ProseBlock;
252
265
  #toolImagesByCallId = new Map<string, ImageContent[]>();
266
+ /** File each grouped read showed that Tern's media viewer shows, for native `media` nodes. */
267
+ #toolMediaPathByCallId = new Map<string, string>();
253
268
  /**
254
269
  * Payload keys ({@link imagePayloadKey}) whose Kitty PNG conversion this
255
270
  * component is awaiting, so a re-delivered image neither re-encodes nor
@@ -798,7 +813,8 @@ export class AssistantMessageComponent extends Container {
798
813
  * head's `title`. With `hideThinkingBlock` only the live head shows (collapsed)
799
814
  * and a finished thought emits nothing.
800
815
  */
801
- override describe(): NativeNode {
816
+ override describe(cx?: DescribeContext): NativeNode {
817
+ const media = cx?.supports("media") === true;
802
818
  const tail = this.#displayedMessage ? this.#thinkingTailIndex(this.#displayedMessage) : undefined;
803
819
  const rate =
804
820
  tail !== undefined && this.#lastUpdateTransient && this.#thinkingRateLive
@@ -813,9 +829,10 @@ export class AssistantMessageComponent extends Container {
813
829
  this.#errorExpanded,
814
830
  this.#nativeViewVersion,
815
831
  rate,
832
+ media,
816
833
  getThemeEpoch(),
817
834
  ];
818
- return this.#native.get(key, () => this.#describeMessage(tail, rate));
835
+ return this.#native.get(key, () => this.#describeMessage(tail, rate, media));
819
836
  }
820
837
 
821
838
  /**
@@ -828,10 +845,10 @@ export class AssistantMessageComponent extends Container {
828
845
  this.#nativeViewVersion++;
829
846
  }
830
847
 
831
- /** Mirrors a thinking section collapsed or expanded in the terminal. */
848
+ /** Mirrors a thinking section collapsed or expanded in the terminal; runs its controls' clicks. */
832
849
  handleNativeEvent(event: NativeUiEvent): void {
833
850
  if (event.type === "action") {
834
- this.#handleErrorAction(event.act);
851
+ this.#handleAction(event.act);
835
852
  return;
836
853
  }
837
854
  if (event.type !== "toggle" || !event.key.startsWith("k")) return;
@@ -841,7 +858,7 @@ export class AssistantMessageComponent extends Container {
841
858
  this.#nativeViewVersion++;
842
859
  }
843
860
 
844
- #describeMessage(thinkingTail: number | undefined, rate: number): NativeNode {
861
+ #describeMessage(thinkingTail: number | undefined, rate: number, media: boolean): NativeNode {
845
862
  const message = this.#displayedMessage;
846
863
  const previousParts = this.#nativeParts;
847
864
  const parts = new Map<string, { text: string; stream: boolean; node: NativeNode }>();
@@ -854,12 +871,19 @@ export class AssistantMessageComponent extends Container {
854
871
  : {
855
872
  text: source,
856
873
  stream,
857
- node: node("md", stream ? { text: source, stream: true } : { text: source }, undefined, key),
874
+ // A streaming node renders its open link or math span as already closed, as the TUI does.
875
+ node: node(
876
+ "md",
877
+ stream ? { text: speculativeStreamingSource(source), stream: true } : { text: source },
878
+ undefined,
879
+ key,
880
+ ),
858
881
  };
859
882
  parts.set(slot, entry);
860
883
  return entry.node;
861
884
  };
862
885
  const children: NativeChild[] = [];
886
+ let answered = false;
863
887
  if (message) {
864
888
  const live = !this.#transcriptBlockFinalized && this.#lastUpdateTransient;
865
889
  let tailIndex = -1;
@@ -876,7 +900,10 @@ export class AssistantMessageComponent extends Container {
876
900
  const content = message.content[index]!;
877
901
  const streaming = live && index === tailIndex;
878
902
  if (content.type === "text" && canonicalizeMessage(content.text)) {
879
- const source = content.text.trim();
903
+ answered = true;
904
+ // A native terminal sizes table columns by every row it gets: send a growing row once
905
+ // its line ends. Hold before trimming, which would drop the newline ending a finished row.
906
+ const source = (streaming ? holdPartialTableRow(content.text) : content.text).trim();
880
907
  // The terminal renders this source itself and would resolve a relative
881
908
  // link against its own idea of the cwd: hand it the session-resolved
882
909
  // targets (resolved once the segment closes, so never while streaming).
@@ -893,7 +920,7 @@ export class AssistantMessageComponent extends Container {
893
920
  children.push(markdown(`t${index}`, `t${index}`, linked(source, streaming), streaming));
894
921
  continue;
895
922
  }
896
- // A chart follows its table as an SVG image node; the prose splits around it.
923
+ // A chart follows its table as an ```svg figure the terminal colors; the prose splits around it.
897
924
  const segments = splitTableCharts(source, streaming);
898
925
  segments.forEach((segment, part) => {
899
926
  const slot = part === 0 ? `t${index}` : `t${index}.${part}`;
@@ -904,7 +931,7 @@ export class AssistantMessageComponent extends Container {
904
931
  }
905
932
  const chart = lookupTableChart(segment.table);
906
933
  if (chart instanceof Promise) this.#awaitChart(chart);
907
- else if (chart) children.push(describeTableChart(chart, slot));
934
+ else if (chart) children.push(markdown(slot, slot, chartFence(chart), false));
908
935
  });
909
936
  } else if (content.type === "thinking") {
910
937
  const display = resolveThinkingDisplay(content, this.#proseOnlyThinking);
@@ -960,9 +987,26 @@ export class AssistantMessageComponent extends Container {
960
987
  }
961
988
  }
962
989
  if (this.#showImages && this.#showToolResultImages) {
990
+ // Reads of files Tern's media viewer shows: the host loads each file once by path, no bytes uploaded.
991
+ const shownPaths = new Set<string>();
992
+ if (media) {
993
+ for (const [toolCallId, mediaPath] of this.#toolMediaPathByCallId) {
994
+ if (shownPaths.has(mediaPath)) continue;
995
+ shownPaths.add(mediaPath);
996
+ children.push(nativeMediaNode(mediaPath, `r${toolCallId}`));
997
+ }
998
+ }
963
999
  for (const [toolCallId, images] of this.#toolImagesByCallId) {
1000
+ if (media && this.#toolMediaPathByCallId.has(toolCallId)) continue;
964
1001
  images.forEach((image, index) => {
965
- children.push(this.#nativeImages.get(`r${toolCallId}:${index}`, image.data, image.mimeType));
1002
+ children.push(
1003
+ this.#nativeImages.get(
1004
+ `r${toolCallId}:${index}`,
1005
+ image.data,
1006
+ image.mimeType,
1007
+ this.#toolMediaPathByCallId.get(toolCallId),
1008
+ ),
1009
+ );
966
1010
  });
967
1011
  }
968
1012
  }
@@ -983,14 +1027,32 @@ export class AssistantMessageComponent extends Container {
983
1027
  }
984
1028
  if (this.#turnUsage) {
985
1029
  const usage = formatTurnUsage(this.#turnUsage);
1030
+ const line: NativeChild[] = [];
1031
+ // Led by Copy and a dot: `⧉ · 12.4s · 19K tok this turn`.
1032
+ if (answered && hasTranscriptActions()) {
1033
+ line.push(
1034
+ // `copy-answer`, not Tern's local `copy` (that would copy the icon's empty label).
1035
+ node(
1036
+ "icon",
1037
+ {
1038
+ name: "copy",
1039
+ role: "zeta.turn.copy",
1040
+ title: "Copy answer",
1041
+ aria: "Copy answer",
1042
+ actions: { click: "copy-answer" },
1043
+ },
1044
+ undefined,
1045
+ "copy",
1046
+ ),
1047
+ node("text", { text: "·" }, undefined, "dot"),
1048
+ );
1049
+ }
1050
+ line.push(node("text", { text: usage.text }, undefined, "text"));
986
1051
  children.push(
987
1052
  node(
988
1053
  "row",
989
1054
  { role: "zeta.turn.usage", gap: "xs", align: "center", title: usage.title },
990
- [
991
- node("icon", { name: "time" }, undefined, "icon"),
992
- node("text", { text: usage.text }, undefined, "text"),
993
- ],
1055
+ line,
994
1056
  "turn-usage",
995
1057
  ),
996
1058
  );
@@ -1081,11 +1143,24 @@ export class AssistantMessageComponent extends Container {
1081
1143
  return card({ role: "zeta.error", tone: "error", key: "error" }, children);
1082
1144
  }
1083
1145
 
1084
- /** Error frame action clicks: zeta's own retry, clipboard and model-picker paths. */
1085
- #handleErrorAction(act: string): void {
1146
+ /** Control clicks (the error frame's actions, the turn footer's Copy): zeta's own retry, clipboard and model-picker paths. */
1147
+ #handleAction(act: string): void {
1086
1148
  if (act === "retry") runTranscriptAction({ act: "retry" });
1087
1149
  else if (act === "switch-model") runTranscriptAction({ act: "switch-model" });
1088
1150
  else if (act === "copy-error" && this.#errorText) runTranscriptAction({ act: "copy", text: this.#errorText });
1151
+ else if (act === "copy-answer") {
1152
+ const text = this.#answerText();
1153
+ if (text) runTranscriptAction({ act: "copy", text });
1154
+ }
1155
+ }
1156
+
1157
+ /** The answer as the transcript shows it: its text blocks' markdown, a blank line apart, without thinking or tool calls. */
1158
+ #answerText(): string {
1159
+ const blocks: string[] = [];
1160
+ for (const content of this.#displayedMessage?.content ?? []) {
1161
+ if (content.type === "text" && canonicalizeMessage(content.text)) blocks.push(content.text.trim());
1162
+ }
1163
+ return blocks.join("\n\n");
1089
1164
  }
1090
1165
 
1091
1166
  /** Width-independent stable identities for the streamed leading thinking run. */
@@ -1198,17 +1273,23 @@ export class AssistantMessageComponent extends Container {
1198
1273
  // Streaming child: publish Markdown's frozen prefix, and only
1199
1274
  // once non-blank content exists past it — the block's last
1200
1275
  // non-blank line is still being written (thinking's prose fold
1201
- // may rewrite it) and must stay out of published bytes.
1276
+ // may rewrite it) and must stay out of published bytes. A
1277
+ // partial thinking line the fold may still drop does not count.
1278
+ // The frozen prefix is in streamingMarkdownSource form, so
1279
+ // offsets into the block use that form too.
1202
1280
  const raw = md.getLastRenderStableText();
1203
1281
  const frozen = raw.trim();
1204
- if (frozen.length > 0 && /\S/.test(item.lastText.slice(raw.length))) {
1282
+ let past = streamingMarkdownSource(item.lastText).slice(raw.length);
1283
+ if (item.blockType === "thinking") past = past.replace(UNSETTLED_THINKING_TAIL, "");
1284
+ if (frozen.length > 0 && /\S/.test(past)) {
1205
1285
  parts.push({ kind: item.blockType, text: frozen });
1206
1286
  }
1207
1287
  break;
1208
1288
  }
1209
1289
  // Closed child: only the streaming tail may mutate in place, so
1210
- // everything before it is final.
1211
- parts.push({ kind: item.blockType, text: item.lastText });
1290
+ // everything before it is final. Same form as the frozen prefix it
1291
+ // published while streaming, so the snapshot still extends.
1292
+ parts.push({ kind: item.blockType, text: streamingMarkdownSource(item.lastText) });
1212
1293
  itemIndex++;
1213
1294
  continue;
1214
1295
  }
@@ -1493,8 +1574,15 @@ export class AssistantMessageComponent extends Container {
1493
1574
  }
1494
1575
  }
1495
1576
 
1496
- setToolResultImages(toolCallId: string, images: ImageContent[]): void {
1577
+ /**
1578
+ * A grouped read's result images, and `mediaPath`: the file it read that Tern's media viewer
1579
+ * shows (read details), which a native host supporting `media` shows by path instead of the
1580
+ * images. A media-only result (a model, a PDF) passes no images.
1581
+ */
1582
+ setToolResultImages(toolCallId: string, images: ImageContent[], mediaPath?: string): void {
1497
1583
  if (!toolCallId) return;
1584
+ if (mediaPath) this.#toolMediaPathByCallId.set(toolCallId, mediaPath);
1585
+ else this.#toolMediaPathByCallId.delete(toolCallId);
1498
1586
  const validImages = images.filter(img => img.type === "image" && img.data && img.mimeType);
1499
1587
  if (validImages.length === 0) {
1500
1588
  this.#toolImagesByCallId.delete(toolCallId);
@@ -1536,12 +1624,15 @@ export class AssistantMessageComponent extends Container {
1536
1624
  }
1537
1625
  }
1538
1626
 
1539
- #renderImageEntries(entries: Array<{ image: ImageContent; key: string }>, withLeadingSpacer: boolean): void {
1627
+ #renderImageEntries(
1628
+ entries: Array<{ image: ImageContent; key: string; sourcePath?: string }>,
1629
+ withLeadingSpacer: boolean,
1630
+ ): void {
1540
1631
  if (!this.#showImages || entries.length === 0) return;
1541
1632
  this.#convertImagesForKitty(entries);
1542
1633
 
1543
1634
  if (withLeadingSpacer) this.#contentContainer.addChild(new Spacer(1));
1544
- for (const { image, key } of entries) {
1635
+ for (const { image, key, sourcePath } of entries) {
1545
1636
  let displayImage: ImageContent | undefined = image;
1546
1637
  if (TERMINAL.imageProtocol === ImageProtocol.Kitty && image.mimeType !== "image/png") {
1547
1638
  displayImage = this.#kittyConversion(image);
@@ -1555,6 +1646,7 @@ export class AssistantMessageComponent extends Container {
1555
1646
  { fallbackColor: (text: string) => theme.fg("toolOutput", text) },
1556
1647
  {
1557
1648
  ...resolveImageOptions(),
1649
+ sourcePath,
1558
1650
  budget: this.#imageBudget,
1559
1651
  imageKey: key,
1560
1652
  requestRender: this.#onImageUpdate,
@@ -1563,15 +1655,18 @@ export class AssistantMessageComponent extends Container {
1563
1655
  );
1564
1656
  continue;
1565
1657
  }
1566
- this.#contentContainer.addChild(new Text(theme.fg("toolOutput", `[Image: ${image.mimeType}]`), 1, 0));
1658
+ this.#contentContainer.addChild(
1659
+ new Text(theme.fg("toolOutput", linkedImageText(`[Image: ${image.mimeType}]`, image, sourcePath)), 1, 0),
1660
+ );
1567
1661
  }
1568
1662
  }
1569
1663
 
1570
1664
  #renderToolImages(): void {
1571
1665
  if (!this.#showToolResultImages) return;
1572
- const entries = Array.from(this.#toolImagesByCallId.entries()).flatMap(([toolCallId, images]) =>
1573
- images.map((image, index) => ({ image, key: `${toolCallId}:${index}` })),
1574
- );
1666
+ const entries = Array.from(this.#toolImagesByCallId.entries()).flatMap(([toolCallId, images]) => {
1667
+ const sourcePath = this.#toolMediaPathByCallId.get(toolCallId);
1668
+ return images.map((image, index) => ({ image, key: `${toolCallId}:${index}`, sourcePath }));
1669
+ });
1575
1670
  this.#renderImageEntries(entries, true);
1576
1671
  }
1577
1672
 
@@ -1714,7 +1809,8 @@ export class AssistantMessageComponent extends Container {
1714
1809
  this.#lastUpdateTransient = opts?.transient === true;
1715
1810
  // Everything below renders the display form; #lastMessage keeps the
1716
1811
  // verbatim message so re-renders re-derive the reaction deterministically.
1717
- message = this.#displayMessage(message, this.#lastUpdateTransient);
1812
+ // Narration the provider returned as thinking renders as answer text.
1813
+ message = this.#displayMessage(presentNarrationAsAnswer(message), this.#lastUpdateTransient);
1718
1814
  this.#displayedMessage = message;
1719
1815
 
1720
1816
  // Streaming-speed gauge: only a live, in-flight render of the single
@@ -31,6 +31,7 @@ import {
31
31
  import { span, text } from "../native/describe";
32
32
  import { type DescribeContext, type NativeNode, type NativeUiEvent, rootToggleExpanded } from "../native/node";
33
33
  import { NativeImageCache } from "../native/blobs";
34
+ import { imageLinksEnabled, linkedImageSpan, linkedImageText } from "../prompt/image-references";
34
35
  import { Memo } from "../native/memo";
35
36
 
36
37
  const STREAMING_LINE_CAP = PREVIEW_LINES * 5;
@@ -172,14 +173,18 @@ export class BashExecutionComponent extends Container {
172
173
  this.#expanded,
173
174
  this.#showImages,
174
175
  this.#images.length,
176
+ // Image nodes and placeholders carry links only under an enabled policy.
177
+ imageLinksEnabled(),
175
178
  ];
176
179
  return this.#native.get(key, () => {
177
180
  const images = this.#images.map((image, index) =>
178
181
  this.#showImages
179
182
  ? this.#nativeImages.get(`img${index}`, image.data, image.mimeType)
180
183
  : text([
181
- span(
184
+ linkedImageSpan(
182
185
  imageFallback(image.mimeType, getImageDimensions(image.data, image.mimeType) ?? undefined),
186
+ image,
187
+ undefined,
183
188
  "muted",
184
189
  ),
185
190
  ]),
@@ -418,7 +423,7 @@ export class BashExecutionComponent extends Container {
418
423
  } else {
419
424
  const dimensions = getImageDimensions(image.data, image.mimeType) ?? undefined;
420
425
  this.#contentContainer.addChild(
421
- new Text(theme.fg("muted", imageFallback(image.mimeType, dimensions)), 1, 0),
426
+ new Text(theme.fg("muted", linkedImageText(imageFallback(image.mimeType, dimensions), image)), 1, 0),
422
427
  );
423
428
  }
424
429
  }
@@ -1,5 +1,5 @@
1
1
  import type { ImageBudget } from "../components/image";
2
- import type { Markdown } from "../components/markdown";
2
+ import { type Markdown } from "../components/markdown";
3
3
  import { Container } from "../tui";
4
4
  export interface FigureMarkdownOptions {
5
5
  /** Markdown for one prose run (and a failed figure's code fallback); FigureMarkdown drives its transience. */
@@ -32,7 +32,12 @@ export declare class FigureMarkdown extends Container {
32
32
  /**
33
33
  * The trimmed prose ahead of the first figure, or "" when the block opens
34
34
  * with one. These are the only rows plain Markdown reproduces byte for
35
- * byte, so they are all a transcript may publish ahead of the figure.
35
+ * byte, so they are all a transcript may publish ahead of the figure. While
36
+ * that prose is still the streaming tail (no figure follows it yet), only
37
+ * its settled prefix ({@link Markdown.getLastRenderStableText}) qualifies:
38
+ * the rest is still being written. Both forms are in
39
+ * {@link streamingMarkdownSource} form, so the settled prefix stays a byte
40
+ * prefix of the whole run once a figure splits it off.
36
41
  */
37
42
  get leadingProse(): string;
38
43
  /** Whether a figure's final source still waits for its raster, or a chart for its pick or raster. */
@@ -1,6 +1,6 @@
1
1
  import type { Tokens } from "@linxiraos/pi-utils/marked";
2
2
  import type { ImageBudget } from "../components/image";
3
- import type { Markdown } from "../components/markdown";
3
+ import { type Markdown, streamingMarkdownSource } from "../components/markdown";
4
4
  import { Spacer } from "../components/spacer";
5
5
  import { Container } from "../tui";
6
6
  import { SvgFigure, svgFigureRendering } from "./svg-figure";
@@ -68,11 +68,18 @@ export class FigureMarkdown extends Container {
68
68
  /**
69
69
  * The trimmed prose ahead of the first figure, or "" when the block opens
70
70
  * with one. These are the only rows plain Markdown reproduces byte for
71
- * byte, so they are all a transcript may publish ahead of the figure.
71
+ * byte, so they are all a transcript may publish ahead of the figure. While
72
+ * that prose is still the streaming tail (no figure follows it yet), only
73
+ * its settled prefix ({@link Markdown.getLastRenderStableText}) qualifies:
74
+ * the rest is still being written. Both forms are in
75
+ * {@link streamingMarkdownSource} form, so the settled prefix stays a byte
76
+ * prefix of the whole run once a figure splits it off.
72
77
  */
73
78
  get leadingProse(): string {
74
79
  const first = this.#parts[0];
75
- return first?.kind === "markdown" ? first.text : "";
80
+ if (first?.kind !== "markdown") return "";
81
+ if (this.#transient && this.#parts.length === 1) return first.md.getLastRenderStableText().trim();
82
+ return streamingMarkdownSource(first.text);
76
83
  }
77
84
 
78
85
  /** Whether a figure's final source still waits for its raster, or a chart for its pick or raster. */
@@ -1,5 +1,10 @@
1
1
  import type { ImageContent, Model } from "@linxiraos/pi-ai";
2
- import { formatBytes, parseImageMetadata, SUPPORTED_IMAGE_MIME_TYPES } from "@linxiraos/pi-utils";
2
+ import {
3
+ decodeBase64ImageData,
4
+ formatBytes,
5
+ parseImageMetadata,
6
+ SUPPORTED_IMAGE_MIME_TYPES,
7
+ } from "@linxiraos/pi-utils";
3
8
 
4
9
  export const MAX_IMAGE_INPUT_BYTES = 20 * 1024 * 1024;
5
10
 
@@ -83,11 +88,9 @@ const DECODE_PROBE_EDGE_PX = 1;
83
88
  * truth on both sides. Callers on hot paths must cache the verdict.
84
89
  */
85
90
  export async function imageDecodeFailureReason(image: ImageContent): Promise<string | null> {
86
- if (!/^[A-Za-z0-9+/]*={0,2}$/.test(image.data)) return "invalid base64 image data";
87
- const normalizedData = image.data.replace(/=+$/, "");
88
- const bytes = Buffer.from(image.data, "base64");
89
- if (bytes.length === 0) return "empty image data";
90
- if (bytes.toString("base64").replace(/=+$/, "") !== normalizedData) return "invalid base64 image data";
91
+ const decoded = decodeBase64ImageData(image.data);
92
+ if (decoded.reason !== undefined) return decoded.reason;
93
+ const { bytes } = decoded;
91
94
  const detected = parseImageMetadata(bytes);
92
95
  if (detected && detected.mimeType !== image.mimeType.toLowerCase()) {
93
96
  return `declared ${image.mimeType} but contains ${detected.mimeType}`;