@linxiraos/pi-tui 1.2.0 → 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 (276) hide show
  1. package/CHANGELOG.md +188 -120
  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 +22 -26
  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 +35 -43
  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/agent-hub.ts +2 -2
  157. package/src/overlays/annotation-overlay.ts +422 -168
  158. package/src/overlays/approval-dialog.ts +389 -0
  159. package/src/overlays/ask-dialog.ts +244 -105
  160. package/src/overlays/btw-history-panel.ts +208 -147
  161. package/src/overlays/extensions/extension-list.ts +4 -6
  162. package/src/overlays/hook-editor.ts +90 -32
  163. package/src/overlays/hook-input.ts +105 -19
  164. package/src/overlays/job-tree.ts +177 -0
  165. package/src/overlays/jobs-panel.ts +222 -142
  166. package/src/overlays/login-dialog.ts +201 -91
  167. package/src/overlays/mcp-add-wizard.ts +562 -279
  168. package/src/overlays/model-browser.ts +32 -7
  169. package/src/overlays/model-hub.ts +4 -10
  170. package/src/overlays/model-picker.ts +58 -21
  171. package/src/overlays/pause-screen.ts +81 -23
  172. package/src/overlays/plan-review-overlay.ts +16 -0
  173. package/src/overlays/plugin-selector.ts +17 -1
  174. package/src/overlays/plugin-settings.ts +8 -2
  175. package/src/overlays/queue-mode-selector.ts +10 -0
  176. package/src/overlays/report-panel.ts +6 -2
  177. package/src/overlays/reset-usage-selector.ts +130 -21
  178. package/src/overlays/session-account-selector.ts +36 -0
  179. package/src/overlays/session-info-overlay.ts +199 -76
  180. package/src/overlays/session-selector.ts +15 -10
  181. package/src/overlays/settings-selector.ts +3 -0
  182. package/src/overlays/show-images-selector.ts +1 -0
  183. package/src/overlays/snapcompact-shape-preview.ts +13 -6
  184. package/src/overlays/theme-selector.ts +6 -1
  185. package/src/overlays/thinking-selector.ts +8 -4
  186. package/src/overlays/tree-selector.ts +117 -2
  187. package/src/overlays/usage-dashboard.ts +498 -144
  188. package/src/prompt/composer-cache.ts +4 -6
  189. package/src/prompt/composer.ts +196 -96
  190. package/src/prompt/custom-editor.ts +168 -65
  191. package/src/prompt/image-references.d.ts +22 -0
  192. package/src/prompt/image-references.ts +79 -2
  193. package/src/prompt/internal-url-autocomplete.ts +1 -0
  194. package/src/prompt/macos-spelling.ts +28 -5
  195. package/src/prompt/prose-gate.ts +3 -0
  196. package/src/prompt/queued-messages.ts +9 -2
  197. package/src/prompt/tips.txt +1 -2
  198. package/src/prompt/typing-word.ts +59 -0
  199. package/src/prompt/welcome.ts +256 -423
  200. package/src/render/code-cell.ts +18 -5
  201. package/src/render/hanging-wrap.d.ts +7 -0
  202. package/src/render/hanging-wrap.ts +58 -0
  203. package/src/render/hyperlink.d.ts +2 -0
  204. package/src/render/hyperlink.ts +5 -0
  205. package/src/render/render-utils.d.ts +9 -1
  206. package/src/render/render-utils.ts +16 -2
  207. package/src/render/utils.d.ts +1 -1
  208. package/src/render/utils.ts +1 -1
  209. package/src/render/width-aware-text.ts +6 -5
  210. package/src/setup/scenes/splash.ts +137 -60
  211. package/src/status-line/component.ts +23 -10
  212. package/src/status-line/context-usage.ts +176 -73
  213. package/src/status-line/footer.ts +18 -35
  214. package/src/status-line/host.ts +6 -1
  215. package/src/status-line/segments.ts +16 -5
  216. package/src/status-line/startup.ts +6 -6
  217. package/src/status-line/types.ts +1 -0
  218. package/src/stdin-buffer.d.ts +7 -0
  219. package/src/stdin-buffer.ts +11 -1
  220. package/src/terminal-capabilities.d.ts +16 -1
  221. package/src/terminal-capabilities.ts +66 -28
  222. package/src/terminal-multiplexer.d.ts +7 -0
  223. package/src/terminal-multiplexer.ts +14 -0
  224. package/src/terminal.d.ts +8 -4
  225. package/src/terminal.ts +160 -46
  226. package/src/theme/symbols.d.ts +1 -1
  227. package/src/theme/symbols.ts +1 -0
  228. package/src/theme/theme-class.ts +1 -0
  229. package/src/theme/theme.d.ts +11 -0
  230. package/src/theme/theme.ts +46 -14
  231. package/src/thinking.d.ts +5 -0
  232. package/src/thinking.ts +9 -1
  233. package/src/tmux.d.ts +39 -4
  234. package/src/tmux.ts +110 -16
  235. package/src/tools/ask.ts +20 -9
  236. package/src/tools/ast-edit.d.ts +2 -2
  237. package/src/tools/ast-edit.ts +30 -7
  238. package/src/tools/ast-grep.d.ts +2 -2
  239. package/src/tools/ast-grep.ts +39 -35
  240. package/src/tools/bash.d.ts +6 -1
  241. package/src/tools/bash.ts +15 -9
  242. package/src/tools/debug.ts +2 -1
  243. package/src/tools/default-renderer.ts +3 -2
  244. package/src/tools/edit.d.ts +2 -0
  245. package/src/tools/edit.ts +100 -21
  246. package/src/tools/eval.d.ts +53 -4
  247. package/src/tools/eval.ts +173 -137
  248. package/src/tools/find.d.ts +14 -4
  249. package/src/tools/find.ts +71 -18
  250. package/src/tools/glob.d.ts +3 -3
  251. package/src/tools/glob.ts +51 -17
  252. package/src/tools/grep.d.ts +2 -2
  253. package/src/tools/grep.ts +99 -22
  254. package/src/tools/grouped-file-output.d.ts +9 -4
  255. package/src/tools/grouped-file-output.ts +21 -6
  256. package/src/tools/lsp.ts +30 -18
  257. package/src/tools/mcp.ts +2 -2
  258. package/src/tools/native-view.d.ts +1 -1
  259. package/src/tools/native-view.ts +2 -2
  260. package/src/tools/read.d.ts +32 -10
  261. package/src/tools/read.ts +89 -43
  262. package/src/tools/renderer.d.ts +2 -0
  263. package/src/tools/renderer.ts +2 -0
  264. package/src/tools/task.d.ts +31 -0
  265. package/src/tools/task.ts +40 -7
  266. package/src/tools/todo.ts +3 -2
  267. package/src/tools/wait.ts +16 -30
  268. package/src/tools/web-search.ts +2 -1
  269. package/src/tools/write.d.ts +2 -0
  270. package/src/tools/write.ts +36 -12
  271. package/src/tools/yield.ts +2 -2
  272. package/src/tui.d.ts +20 -3
  273. package/src/tui.ts +140 -36
  274. package/src/utils.ts +5 -3
  275. package/dist/types/tools/eval-format/code-mode-declarations.d.ts +0 -5
  276. package/src/tools/eval-format/code-mode-declarations.ts +0 -61
@@ -104,12 +104,37 @@ interface TranscriptEntry {
104
104
  * so a hit is exact.
105
105
  */
106
106
  imageScan: { width: number; protocol: string | null; hasImageRows: boolean } | undefined;
107
+ /**
108
+ * Head rows of a settled block already in native history while the rest
109
+ * stays live: pressure commits only the rows that left the screen, so no
110
+ * committed row is ever still visible above the viewport.
111
+ */
112
+ split: RowSplit | undefined;
113
+ }
114
+
115
+ /**
116
+ * A committed physical row prefix of one block. `rows` counts trimmed render
117
+ * rows at `width` (any emitted stable prefix included) and `hash` fingerprints
118
+ * them; `text` is their letters and digits in order. A settled block can still
119
+ * change (a tool pane expanding) or rewrap at another width without a replay,
120
+ * so every render re-locates the seam: the committed rows when they render
121
+ * unchanged, else by continuing `text` — per-row decoration (quote bars,
122
+ * borders, rules) is punctuation it ignores.
123
+ */
124
+ interface RowSplit {
125
+ readonly rows: number;
126
+ readonly width: number;
127
+ readonly hash: number | bigint;
128
+ readonly text: string;
129
+ /** Seam of the last render it was located in; renders are memoized per frame. */
130
+ located?: { readonly rendered: readonly string[]; readonly count: number };
107
131
  }
108
132
 
109
133
  type RetirementPolicy = "pressure" | "flush";
110
134
  type Offered =
111
135
  | { batch: HistoryBatch; kind: "append"; entry: number; emittedEnd: number }
112
- | { batch: HistoryBatch; kind: "commit"; end: number }
136
+ /** Whole blocks `[frontier, end)`, then optionally the head rows of block `end`. */
137
+ | { batch: HistoryBatch; kind: "commit"; end: number; split?: RowSplit }
113
138
  | { batch: HistoryBatch; kind: "replay" };
114
139
 
115
140
  /** Rows a progressive-append retirement offers, and the stable count they bring the head to. */
@@ -148,6 +173,24 @@ function isPlainBlank(line: string): boolean {
148
173
  return !/\S/.test(line);
149
174
  }
150
175
 
176
+ /** Letters and digits of `rows` in reading order: the content rewrapping preserves. */
177
+ function contentOf(rows: readonly string[]): string {
178
+ let text = "";
179
+ for (const row of rows) text += Bun.stripANSI(row).replace(/[^\p{L}\p{N}]/gu, "");
180
+ return text;
181
+ }
182
+
183
+ /**
184
+ * Split committing the first `rows` rows of `scrollback` at `width`. The
185
+ * fingerprint covers the scrollback bytes history receives, which can differ
186
+ * from the measured render at the same row count (a SIXEL image encodes
187
+ * synchronously instead of returning its reserved rows).
188
+ */
189
+ function rowSplit(scrollback: readonly string[], rows: number, width: number): RowSplit {
190
+ const committed = scrollback.slice(0, rows);
191
+ return { rows, width, hash: Bun.hash(committed.join("\n")), text: contentOf(committed) };
192
+ }
193
+
151
194
  /** Whether `prefix` matches `rows` byte-for-byte from the top. */
152
195
  export function isRowPrefix(prefix: readonly string[], rows: readonly string[]): boolean {
153
196
  if (prefix.length > rows.length) return false;
@@ -235,6 +278,7 @@ export class TranscriptContainer extends Container {
235
278
  emitted: 0,
236
279
  stableFrozen: false,
237
280
  imageScan: undefined,
281
+ split: undefined,
238
282
  });
239
283
  // `children` is public and may be edited externally; re-check the whole
240
284
  // list once instead of trusting positions. Insert-after-anchor callers
@@ -309,13 +353,16 @@ export class TranscriptContainer extends Container {
309
353
  */
310
354
  resetStableEmission(): void {
311
355
  this.#syncEntries();
312
- if (this.#offered?.kind === "append") this.#offered = undefined;
356
+ if (this.#offered?.kind === "append" || (this.#offered?.kind === "commit" && this.#offered.split)) {
357
+ this.#offered = undefined;
358
+ }
313
359
  for (const entry of this.#entries) {
314
360
  entry.emitted = 0;
315
361
  entry.stableRows = EMPTY_STABLE_ROWS;
316
362
  entry.renderedStableByWidth = new Map();
317
363
  entry.stableRowCountByWidth = new Map();
318
364
  entry.stableFrozen = false;
365
+ entry.split = undefined;
319
366
  if (entry.mode === "appendOnly") {
320
367
  (entry.component as Component & AppendOnlyTranscriptBlock).resetTranscriptStableRows?.();
321
368
  }
@@ -338,8 +385,8 @@ export class TranscriptContainer extends Container {
338
385
  const index = this.#entries.findLastIndex(entry => entry.component === component);
339
386
  if (index < 0) return -1;
340
387
  const entry = this.#entries[index]!;
341
- if (entry.state === "committed" || entry.emitted > 0) return -1;
342
- if (this.#offered?.kind === "commit" && index < this.#offered.end) return -1;
388
+ if (entry.state === "committed" || entry.emitted > 0 || entry.split !== undefined) return -1;
389
+ if (this.#offered?.kind === "commit" && index < this.#offered.end + (this.#offered.split ? 1 : 0)) return -1;
343
390
  if (this.#offered?.kind === "append" && index === this.#offered.entry) return -1;
344
391
  return index;
345
392
  }
@@ -374,6 +421,16 @@ export class TranscriptContainer extends Container {
374
421
  return this.#entries.map(entry => entry.emitted);
375
422
  }
376
423
 
424
+ /** Whether any block is still streaming or otherwise unfinalized. */
425
+ hasActiveBlock(): boolean {
426
+ this.#syncEntries();
427
+ this.#settleFinalized();
428
+ for (let index = this.#frontier; index < this.#entries.length; index++) {
429
+ if (this.#entries[index]!.state === "active") return true;
430
+ }
431
+ return false;
432
+ }
433
+
377
434
  /** Whether visible active capacity and live-block memory permit another admission. */
378
435
  canAdmit(rows: number): boolean {
379
436
  const active = this.#entries.filter(entry => entry.state === "active").length;
@@ -443,17 +500,21 @@ export class TranscriptContainer extends Container {
443
500
 
444
501
  /**
445
502
  * One block's blank-trimmed render at its full-height allocation. Inside an
446
- * open frame the first measurement of each block is replayed to later ones.
503
+ * open frame the first measurement of each block is replayed to later ones,
504
+ * leaving whatever allocation the viewport has since rendered it at.
447
505
  */
448
506
  #measuredRows(entry: TranscriptEntry, width: number): readonly string[] {
449
- this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
450
- if (this.#openFrame === undefined) return this.#renderEntry(entry, width);
507
+ if (this.#openFrame === undefined) {
508
+ this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
509
+ return this.#renderEntry(entry, width);
510
+ }
451
511
  if (this.#frameRowsWidth !== width) {
452
512
  this.#frameRows.clear();
453
513
  this.#frameRowsWidth = width;
454
514
  }
455
515
  let rows = this.#frameRows.get(entry);
456
516
  if (rows === undefined) {
517
+ this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
457
518
  rows = this.#renderEntry(entry, width);
458
519
  this.#frameRows.set(entry, rows);
459
520
  }
@@ -655,6 +716,20 @@ export class TranscriptContainer extends Container {
655
716
  rows = after.slice(before.length);
656
717
  } else if (offered.kind === "commit") {
657
718
  rows = this.#renderRange(this.#frontier, offered.end, width, true).rows;
719
+ const head = offered.split ? this.#entries[offered.end] : undefined;
720
+ if (head !== undefined && offered.split !== undefined) {
721
+ // The offer's fingerprint covers bytes of a discarded frame history
722
+ // never received (an image-budget retry demotes graphics to text),
723
+ // so the split keeps its cut and re-fingerprints the rows it now
724
+ // delivers. Only another width relocates the cut.
725
+ const start = this.#emittedRowCount(head, width);
726
+ const scrollback = renderForScrollback(() => this.#renderEntry(head, width));
727
+ const cut =
728
+ width === offered.split.width ? offered.split.rows : this.#splitRowCount(head, offered.split, width);
729
+ const end = Math.min(cut, scrollback.length);
730
+ offered.split = rowSplit(scrollback, end, width);
731
+ rows = [...rows, ...scrollback.slice(start, end)];
732
+ }
658
733
  } else {
659
734
  rows = this.#renderReplay(width);
660
735
  }
@@ -683,6 +758,7 @@ export class TranscriptContainer extends Container {
683
758
  head?.mode === "appendOnly" &&
684
759
  !head.stableFrozen &&
685
760
  head.state !== "committed" &&
761
+ head.split === undefined &&
686
762
  head.emitted < head.stableRows.length
687
763
  ? head
688
764
  : undefined;
@@ -762,9 +838,26 @@ export class TranscriptContainer extends Container {
762
838
  limit = Math.max(limit, this.#frontier + (this.#liveCount() - (MAX_LIVE_BLOCKS - 1)));
763
839
  }
764
840
  if (policy === "flush") limit = this.#entries.length;
841
+ // The block straddling the top edge commits only the rows that left the
842
+ // screen; the rest stays live, so a later shrink of the chrome or the
843
+ // tail refills the screen from live rows instead of opening a band no
844
+ // frame can fill (rows still on screen cannot return from scrollback).
845
+ let split: { index: number; rows: readonly string[]; split: RowSplit } | undefined;
846
+ if (policy === "pressure" && keep > 0 && limit === this.#frontier + keep) {
847
+ const boundary = live[keep - 1]!;
848
+ const visible = room - tailRows - (tailRows > 0 ? 1 : 0);
849
+ if (boundary.entry.state === "settled" && visible > 0) {
850
+ const offer = this.#splitOffer(boundary.entry, width, visible);
851
+ if (offer !== undefined) {
852
+ split = { index: boundary.index, ...offer };
853
+ limit = boundary.index;
854
+ }
855
+ }
856
+ }
765
857
  let end = this.#frontier;
766
858
  while (end < limit && end < this.#entries.length && this.#entries[end]!.state === "settled") end++;
767
- if (end === this.#frontier) {
859
+ if (split !== undefined && end !== split.index) split = undefined;
860
+ if (end === this.#frontier && split === undefined) {
768
861
  if (policy === "pressure") this.#notePinnedFrontier();
769
862
  return undefined;
770
863
  }
@@ -784,15 +877,35 @@ export class TranscriptContainer extends Container {
784
877
  } finally {
785
878
  popLoopPhase();
786
879
  }
880
+ if (split !== undefined && retirement.end !== split.index) split = undefined;
787
881
  const batch: HistoryBatch = {
788
882
  id: this.#nextBatchId++,
789
- rows: retirement.rows,
883
+ rows: split === undefined ? retirement.rows : [...retirement.rows, ...split.rows],
790
884
  kind: "append",
791
885
  };
792
- this.#offered = { batch, end: retirement.end, kind: "commit" };
886
+ this.#offered = { batch, end: retirement.end, kind: "commit", split: split?.split };
793
887
  return batch;
794
888
  }
795
889
 
890
+ /**
891
+ * The head rows of settled `entry` beyond its committed prefix that leave
892
+ * `visible` rows live, or undefined when nothing would remain committed
893
+ * past that prefix or the scrollback render would not line up row for row.
894
+ */
895
+ #splitOffer(
896
+ entry: TranscriptEntry,
897
+ width: number,
898
+ visible: number,
899
+ ): { rows: readonly string[]; split: RowSplit } | undefined {
900
+ const measured = this.#measuredRows(entry, width);
901
+ const start = this.#emittedRowCount(entry, width);
902
+ const total = measured.length - visible;
903
+ if (total <= start) return undefined;
904
+ const scrollback = renderForScrollback(() => this.#renderEntry(entry, width));
905
+ if (scrollback.length !== measured.length) return undefined;
906
+ return { rows: scrollback.slice(start, total), split: rowSplit(scrollback, total, width) };
907
+ }
908
+
796
909
  /** Acknowledges exactly the most recently offered append, commit, or replay transaction. */
797
910
  acknowledgeFinalizedBatch(id: number): void {
798
911
  const offered = this.#offered;
@@ -809,6 +922,8 @@ export class TranscriptContainer extends Container {
809
922
  this.#retireEntry(this.#entries[index]!);
810
923
  }
811
924
  this.#frontier = offered.end;
925
+ const head = offered.split ? this.#entries[offered.end] : undefined;
926
+ if (head !== undefined) head.split = offered.split;
812
927
  } else {
813
928
  this.#releaseCommittedRenderCaches();
814
929
  }
@@ -951,14 +1066,32 @@ export class TranscriptContainer extends Container {
951
1066
  }
952
1067
 
953
1068
  /**
954
- * Length-only variant of `#renderStablePrefix`: answers the projected
955
- * emitted row count without re-rendering the prefix. The container only
956
- * needs the length for slicing; the render call it replaced existed
957
- * purely to read `.length` off the result.
1069
+ * Rows of `entry` at `width` already in native history or in the
1070
+ * unacknowledged offer: the row split when one is pending or committed,
1071
+ * else its emitted stable prefix.
958
1072
  */
959
1073
  #projectedEmittedRowCount(entry: TranscriptEntry, index: number, width: number): number {
960
1074
  const offered = this.#offered;
961
- const count = offered?.kind === "append" && offered.entry === index ? offered.emittedEnd : entry.emitted;
1075
+ if (offered?.kind === "commit" && offered.split !== undefined && offered.end === index) {
1076
+ return this.#splitRowCount(entry, offered.split, width);
1077
+ }
1078
+ if (offered?.kind === "append" && offered.entry === index && entry.split === undefined) {
1079
+ return this.#stablePrefixRowCount(entry, offered.emittedEnd, width);
1080
+ }
1081
+ return this.#emittedRowCount(entry, width);
1082
+ }
1083
+
1084
+ /** Rows of `entry` at `width` already acknowledged into native history. */
1085
+ #emittedRowCount(entry: TranscriptEntry, width: number): number {
1086
+ if (entry.split !== undefined) return this.#splitRowCount(entry, entry.split, width);
1087
+ return this.#stablePrefixRowCount(entry, entry.emitted, width);
1088
+ }
1089
+
1090
+ /**
1091
+ * Length-only variant of `#renderStablePrefix`: answers the emitted row
1092
+ * count without re-rendering the prefix when this width already recorded it.
1093
+ */
1094
+ #stablePrefixRowCount(entry: TranscriptEntry, count: number, width: number): number {
962
1095
  if (count === 0) return 0;
963
1096
  const perCount = entry.stableRowCountByWidth.get(width);
964
1097
  const memo = perCount?.get(Math.min(count, entry.stableRows.length));
@@ -966,6 +1099,39 @@ export class TranscriptContainer extends Container {
966
1099
  return this.#renderStablePrefix(entry, count, width).length;
967
1100
  }
968
1101
 
1102
+ /**
1103
+ * Rows of `entry`'s current render at `width` that `split` committed: all
1104
+ * of them while they render unchanged, else the longest prefix whose
1105
+ * content continues the committed text exactly. A row the seam falls
1106
+ * inside, a row the block changed, a row with no content (a rule or other
1107
+ * punctuation the block revealed), or one whose decoration reads as
1108
+ * content stays live: it can repeat committed text but never drop any.
1109
+ */
1110
+ #splitRowCount(entry: TranscriptEntry, split: RowSplit, width: number): number {
1111
+ const rendered = this.#measuredRows(entry, width);
1112
+ if (split.located?.rendered === rendered) return split.located.count;
1113
+ let count = 0;
1114
+ if (
1115
+ width === split.width &&
1116
+ rendered.length >= split.rows &&
1117
+ Bun.hash(rendered.slice(0, split.rows).join("\n")) === split.hash
1118
+ ) {
1119
+ count = split.rows;
1120
+ } else {
1121
+ let consumed = 0;
1122
+ while (count < rendered.length && consumed < split.text.length) {
1123
+ const row = contentOf([rendered[count]!]);
1124
+ // An empty-content row matches at any offset, so the walk cannot
1125
+ // tell it apart from a revealed row history never received.
1126
+ if (row.length === 0 || !split.text.startsWith(row, consumed)) break;
1127
+ consumed += row.length;
1128
+ count++;
1129
+ }
1130
+ }
1131
+ split.located = { rendered, count };
1132
+ return count;
1133
+ }
1134
+
969
1135
  /**
970
1136
  * Size a progressive-append batch from the stable renders `#renderEntry`
971
1137
  * recorded at this width, without rendering any prefix. Each recorded count
@@ -973,7 +1139,8 @@ export class TranscriptContainer extends Container {
973
1139
  * `renderedStableByWidth` holds the newest, so each count's rows are a
974
1140
  * prefix of it and the batch is one slice. Undefined when a count the walk
975
1141
  * needs was never recorded here (published between renders, or before a
976
- * resize); the rendered walk then decides.
1142
+ * resize); the rendered walk then decides. Like the rendered walk, it never
1143
+ * passes `overflow`: a prefix reaching onto the screen waits.
977
1144
  */
978
1145
  #measuredAppendBatch(entry: TranscriptEntry, width: number, overflow: number): AppendBatch | undefined {
979
1146
  const counts = entry.stableRowCountByWidth.get(width);
@@ -984,7 +1151,7 @@ export class TranscriptContainer extends Container {
984
1151
  if (start === undefined) return undefined;
985
1152
  let emittedEnd = entry.emitted;
986
1153
  let end = start;
987
- while (emittedEnd < target && end - start < overflow) {
1154
+ while (emittedEnd < target) {
988
1155
  const next = counts.get(emittedEnd + 1);
989
1156
  if (next === undefined) return undefined;
990
1157
  if (next <= start) {
@@ -993,6 +1160,7 @@ export class TranscriptContainer extends Container {
993
1160
  }
994
1161
  break;
995
1162
  }
1163
+ if (next - start > overflow) break;
996
1164
  end = next;
997
1165
  emittedEnd += 1;
998
1166
  }
@@ -1001,15 +1169,18 @@ export class TranscriptContainer extends Container {
1001
1169
 
1002
1170
  /**
1003
1171
  * Size a progressive-append batch by rendering each further stable prefix:
1004
- * extend the emitted prefix until its new rows cover `overflow`, stopping
1005
- * at the first prefix that adds no row or stops extending the emitted one
1006
- * (freezing the block when that is the very next prefix).
1172
+ * extend the emitted prefix while its new rows stay within `overflow` — the
1173
+ * rows that left the screen — stopping at the first prefix that would reach
1174
+ * onto it, adds no row, or stops extending the emitted one (freezing the
1175
+ * block when that is the very next prefix). A prefix still partly on screen
1176
+ * stays live: committing it would strand those rows above the live tail,
1177
+ * where a later shrink of the chrome below opens a band no frame can fill.
1007
1178
  */
1008
1179
  #renderedAppendBatch(entry: TranscriptEntry, width: number, overflow: number): AppendBatch {
1009
1180
  const before = this.#renderStablePrefix(entry, entry.emitted, width);
1010
1181
  let emittedEnd = entry.emitted;
1011
1182
  let after = before;
1012
- while (emittedEnd < entry.stableRows.length && after.length - before.length < overflow) {
1183
+ while (emittedEnd < entry.stableRows.length) {
1013
1184
  const next = this.#renderStablePrefix(entry, emittedEnd + 1, width);
1014
1185
  if (!isRowPrefix(before, next) || next.length === before.length) {
1015
1186
  if (emittedEnd === entry.emitted) {
@@ -1017,6 +1188,7 @@ export class TranscriptContainer extends Container {
1017
1188
  }
1018
1189
  break;
1019
1190
  }
1191
+ if (next.length - before.length > overflow) break;
1020
1192
  after = next;
1021
1193
  emittedEnd += 1;
1022
1194
  }
@@ -1078,7 +1250,7 @@ export class TranscriptContainer extends Container {
1078
1250
  const rendered = renderForScrollback(() =>
1079
1251
  index === start ? this.#renderEntry(entry, width) : trimBlankEdges(entry.component.render(width)),
1080
1252
  );
1081
- const emittedRows = index === start ? this.#renderStablePrefix(entry, entry.emitted, width).length : 0;
1253
+ const emittedRows = index === start ? this.#emittedRowCount(entry, width) : 0;
1082
1254
  const block = rendered.slice(emittedRows);
1083
1255
  reached = index + 1;
1084
1256
  if (block.length > 0) {
@@ -1094,7 +1266,11 @@ export class TranscriptContainer extends Container {
1094
1266
  #renderReplay(width: number): readonly string[] {
1095
1267
  const rows = Array.from(this.#renderRange(0, this.#frontier, width, true).rows);
1096
1268
  const head = this.#entries[this.#frontier];
1097
- if (head?.mode === "appendOnly" && head.emitted > 0) {
1269
+ if (head?.split !== undefined) {
1270
+ this.#setAllocation(head.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
1271
+ const committed = this.#splitRowCount(head, head.split, width);
1272
+ rows.push(...renderForScrollback(() => this.#renderEntry(head, width)).slice(0, committed));
1273
+ } else if (head?.mode === "appendOnly" && head.emitted > 0) {
1098
1274
  this.#setAllocation(head.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
1099
1275
  this.#renderEntry(head, width);
1100
1276
  rows.push(...this.#renderStablePrefix(head, head.emitted, width));
@@ -1105,10 +1281,15 @@ export class TranscriptContainer extends Container {
1105
1281
  #completeFullyEmittedHeads(width: number): void {
1106
1282
  while (this.#frontier < this.#entries.length) {
1107
1283
  const entry = this.#entries[this.#frontier]!;
1108
- if (entry.mode !== "appendOnly" || entry.state !== "settled") return;
1109
- const rendered = this.#measuredRows(entry, width);
1110
- if (entry.emitted !== entry.stableRows.length) return;
1111
- if (this.#renderStablePrefix(entry, entry.emitted, width).length !== rendered.length) return;
1284
+ if (entry.state !== "settled") return;
1285
+ if (entry.split !== undefined) {
1286
+ if (this.#splitRowCount(entry, entry.split, width) < this.#measuredRows(entry, width).length) return;
1287
+ } else {
1288
+ if (entry.mode !== "appendOnly") return;
1289
+ const rendered = this.#measuredRows(entry, width);
1290
+ if (entry.emitted !== entry.stableRows.length) return;
1291
+ if (this.#renderStablePrefix(entry, entry.emitted, width).length !== rendered.length) return;
1292
+ }
1112
1293
  this.#retireEntry(entry);
1113
1294
  this.#frontier++;
1114
1295
  }
@@ -1117,6 +1298,7 @@ export class TranscriptContainer extends Container {
1117
1298
  #retireEntry(entry: TranscriptEntry): void {
1118
1299
  entry.state = "committed";
1119
1300
  entry.emitted = 0;
1301
+ entry.split = undefined;
1120
1302
  entry.stableRows = EMPTY_STABLE_ROWS;
1121
1303
  entry.renderedStableByWidth = new Map();
1122
1304
  entry.stableRowCountByWidth = new Map();
@@ -1147,7 +1329,8 @@ export class TranscriptContainer extends Container {
1147
1329
 
1148
1330
  #startReplay(): void {
1149
1331
  const head = this.#entries[this.#frontier];
1150
- this.#replayPending = this.#frontier > 0 || (head?.mode === "appendOnly" && head.emitted > 0);
1332
+ this.#replayPending =
1333
+ this.#frontier > 0 || head?.split !== undefined || (head?.mode === "appendOnly" && head.emitted > 0);
1151
1334
  this.#replayRequested = false;
1152
1335
  }
1153
1336
 
@@ -1355,6 +1538,7 @@ export class TranscriptContainer extends Container {
1355
1538
  emitted: 0,
1356
1539
  stableFrozen: false,
1357
1540
  imageScan: undefined,
1541
+ split: undefined,
1358
1542
  },
1359
1543
  );
1360
1544
  this.#frontier = this.#entries.findIndex(entry => entry.state !== "committed");
@@ -4,6 +4,7 @@ import {
4
4
  type AutocompleteItem,
5
5
  type AutocompleteProvider,
6
6
  atCompletionMatches,
7
+ extractAtFilePrefix,
7
8
  findLeadingSlashCommandStart,
8
9
  findTrailingSlashCommandStart,
9
10
  isDirectoryCompletionValue,
@@ -99,7 +100,6 @@ function sameDecorations(a: readonly TspEditorDecoration[], b: readonly TspEdito
99
100
  return true;
100
101
  }
101
102
  const MENTION_CONTEXT_RE = /(?:^|\s)\^[^\s]*$/;
102
- const AT_TOKEN_RE = /(?:^|\s)(@[^\s]*)$/;
103
103
 
104
104
  const AUTOCOMPLETE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
105
105
  overflowSearch: false,
@@ -1769,7 +1769,7 @@ export class Editor implements Component, Focusable {
1769
1769
  let typed = this.#autocompletePrefix;
1770
1770
  if (typed.startsWith("@")) {
1771
1771
  const line = this.#state.lines[this.#state.cursorLine] ?? "";
1772
- typed = AT_TOKEN_RE.exec(line.slice(0, this.#state.cursorCol))?.[1] ?? typed;
1772
+ typed = extractAtFilePrefix(line.slice(0, this.#state.cursorCol)) ?? typed;
1773
1773
  }
1774
1774
  typed = typed.replace(/^[/@:#$^]"?/, "");
1775
1775
  return typed.slice(typed.lastIndexOf("/") + 1);
@@ -3353,7 +3353,7 @@ export class Editor implements Component, Focusable {
3353
3353
  this.#tryTriggerAutocomplete();
3354
3354
  }
3355
3355
  // Check if we're in an @ file reference context
3356
- else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
3356
+ else if (extractAtFilePrefix(textBeforeCursor)) {
3357
3357
  this.#tryTriggerAutocomplete();
3358
3358
  }
3359
3359
  // Check if we're in a model mention context
@@ -3477,7 +3477,7 @@ export class Editor implements Component, Focusable {
3477
3477
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
3478
3478
  if (this.#isInSlashAutocompleteContext()) {
3479
3479
  this.#tryTriggerAutocomplete();
3480
- } else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
3480
+ } else if (extractAtFilePrefix(textBeforeCursor)) {
3481
3481
  this.#tryTriggerAutocomplete();
3482
3482
  } else if (MENTION_CONTEXT_RE.test(textBeforeCursor)) {
3483
3483
  this.#tryTriggerAutocomplete();
@@ -3680,7 +3680,7 @@ export class Editor implements Component, Focusable {
3680
3680
  this.#tryTriggerAutocomplete();
3681
3681
  }
3682
3682
  // @ file reference context
3683
- else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
3683
+ else if (extractAtFilePrefix(textBeforeCursor)) {
3684
3684
  this.#tryTriggerAutocomplete();
3685
3685
  }
3686
3686
  // model mention context
@@ -3855,7 +3855,7 @@ export class Editor implements Component, Focusable {
3855
3855
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
3856
3856
  if (this.#isInSlashAutocompleteContext()) {
3857
3857
  this.#tryTriggerAutocomplete();
3858
- } else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
3858
+ } else if (extractAtFilePrefix(textBeforeCursor)) {
3859
3859
  this.#tryTriggerAutocomplete();
3860
3860
  } else if (MENTION_CONTEXT_RE.test(textBeforeCursor)) {
3861
3861
  this.#tryTriggerAutocomplete();
@@ -4192,7 +4192,7 @@ export class Editor implements Component, Focusable {
4192
4192
  this.#tryTriggerAutocomplete();
4193
4193
  }
4194
4194
  // @ file reference context
4195
- else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
4195
+ else if (extractAtFilePrefix(textBeforeCursor)) {
4196
4196
  this.#tryTriggerAutocomplete();
4197
4197
  }
4198
4198
  // model mention context
@@ -4453,8 +4453,8 @@ export class Editor implements Component, Focusable {
4453
4453
  * - Mid-prompt skill branch re-anchors when the popup item is a skill and the
4454
4454
  * current text still ends in a matching trailing slash token, preventing a
4455
4455
  * stale selection from replacing a newer skill prefix.
4456
- * - `@`-file branch re-anchors via `#extractAtPrefix`; safe when the current text
4457
- * still ends in a whitespace-anchored `@<token>`.
4456
+ * - `@`-file branch re-anchors via `extractAtFilePrefix`; safe when the current text
4457
+ * still ends in a whitespace-anchored or double-quoted `@` token.
4458
4458
  * - Everything else is stale — accepting it would corrupt the buffer (issue #4295).
4459
4459
  */
4460
4460
  #autocompletePrefixMatchesCursorText(currentTextBeforeCursor: string, item?: SelectItem | null): boolean {
@@ -4489,7 +4489,7 @@ export class Editor implements Component, Focusable {
4489
4489
  }
4490
4490
 
4491
4491
  if (this.#autocompletePrefix.startsWith("@")) {
4492
- return /(?:^|\s)@[^\s]*$/.test(currentTextBeforeCursor);
4492
+ return extractAtFilePrefix(currentTextBeforeCursor) !== null;
4493
4493
  }
4494
4494
 
4495
4495
  if (this.#autocompletePrefix.startsWith("^")) {
@@ -4884,8 +4884,8 @@ export class Editor implements Component, Focusable {
4884
4884
  #narrowAtFileList(): void {
4885
4885
  if (!this.#autocompleteList || !this.#autocompletePrefix.startsWith("@")) return;
4886
4886
  const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
4887
- const token = AT_TOKEN_RE.exec(currentLine.slice(0, this.#state.cursorCol))?.[1];
4888
- if (token !== undefined) this.#autocompleteList.setFilter(token);
4887
+ const token = extractAtFilePrefix(currentLine.slice(0, this.#state.cursorCol));
4888
+ if (token !== null) this.#autocompleteList.setFilter(token);
4889
4889
  }
4890
4890
 
4891
4891
  #invalidateAutocompleteRequests(): void {
@@ -8,6 +8,8 @@ export interface ImageOptions {
8
8
  maxWidthCells?: number;
9
9
  maxHeightCells?: number;
10
10
  filename?: string;
11
+ /** File the image was read from; its link target. Images without one link to a blob copy of their bytes. */
12
+ sourcePath?: string;
11
13
  /** Shared budget that caps how many inline images render as live graphics. */
12
14
  budget?: ImageBudget;
13
15
  /**