@oh-my-pi/pi-tui 18.9.2 → 18.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +53 -105
  2. package/dist/types/app-keybindings.d.ts +5 -0
  3. package/dist/types/chat/assistant-message.d.ts +1 -1
  4. package/dist/types/chat/user-message.d.ts +3 -2
  5. package/dist/types/chrome/countdown-timer.d.ts +3 -2
  6. package/dist/types/debug-server.d.ts +2 -0
  7. package/dist/types/overlays/annotation-overlay.d.ts +19 -0
  8. package/dist/types/overlays/ask-dialog.d.ts +11 -6
  9. package/dist/types/overlays/btw-history-panel.d.ts +2 -6
  10. package/dist/types/overlays/hook-editor.d.ts +7 -2
  11. package/dist/types/overlays/hook-input.d.ts +12 -4
  12. package/dist/types/overlays/jobs-panel.d.ts +8 -7
  13. package/dist/types/overlays/login-dialog.d.ts +8 -3
  14. package/dist/types/overlays/mcp-add-wizard.d.ts +9 -1
  15. package/dist/types/overlays/pause-screen.d.ts +5 -0
  16. package/dist/types/overlays/report-panel.d.ts +5 -1
  17. package/dist/types/overlays/reset-usage-selector.d.ts +7 -5
  18. package/dist/types/overlays/session-account-selector.d.ts +4 -0
  19. package/dist/types/overlays/session-info-overlay.d.ts +7 -3
  20. package/dist/types/overlays/usage-dashboard.d.ts +6 -6
  21. package/dist/types/prompt/composer.d.ts +1 -20
  22. package/dist/types/status-line/context-usage.d.ts +1 -1
  23. package/dist/types/tools/grouped-file-output.d.ts +4 -1
  24. package/dist/types/tui.d.ts +10 -3
  25. package/package.json +9 -9
  26. package/src/app-keybindings.ts +5 -0
  27. package/src/apps/autoresearch-dashboard.ts +440 -27
  28. package/src/chat/assistant-message.ts +41 -13
  29. package/src/chat/user-message.ts +32 -19
  30. package/src/chrome/countdown-timer.ts +3 -3
  31. package/src/chrome/transcript-container.ts +202 -28
  32. package/src/debug-server.ts +51 -69
  33. package/src/hotkeys-markdown.ts +1 -0
  34. package/src/native/reconcile.ts +24 -6
  35. package/src/overlays/advisor-config.ts +259 -8
  36. package/src/overlays/annotation-overlay.ts +420 -168
  37. package/src/overlays/ask-dialog.ts +249 -105
  38. package/src/overlays/btw-history-panel.ts +178 -124
  39. package/src/overlays/hook-editor.ts +90 -32
  40. package/src/overlays/hook-input.ts +105 -19
  41. package/src/overlays/jobs-panel.ts +154 -132
  42. package/src/overlays/login-dialog.ts +201 -91
  43. package/src/overlays/mcp-add-wizard.ts +559 -279
  44. package/src/overlays/pause-screen.ts +81 -23
  45. package/src/overlays/plugin-selector.ts +17 -1
  46. package/src/overlays/queue-mode-selector.ts +10 -0
  47. package/src/overlays/report-panel.ts +6 -2
  48. package/src/overlays/reset-usage-selector.ts +130 -21
  49. package/src/overlays/session-account-selector.ts +36 -0
  50. package/src/overlays/session-info-overlay.ts +197 -76
  51. package/src/overlays/show-images-selector.ts +1 -0
  52. package/src/overlays/theme-selector.ts +6 -1
  53. package/src/overlays/thinking-selector.ts +8 -4
  54. package/src/overlays/usage-dashboard.ts +495 -143
  55. package/src/prompt/composer.ts +35 -72
  56. package/src/status-line/context-usage.ts +154 -57
  57. package/src/tools/grep.ts +1 -0
  58. package/src/tools/grouped-file-output.ts +11 -2
  59. package/src/tui.ts +27 -4
@@ -828,10 +828,10 @@ export class AssistantMessageComponent extends Container {
828
828
  this.#nativeViewVersion++;
829
829
  }
830
830
 
831
- /** Mirrors a thinking section collapsed or expanded in the terminal. */
831
+ /** Mirrors a thinking section collapsed or expanded in the terminal; runs its controls' clicks. */
832
832
  handleNativeEvent(event: NativeUiEvent): void {
833
833
  if (event.type === "action") {
834
- this.#handleErrorAction(event.act);
834
+ this.#handleAction(event.act);
835
835
  return;
836
836
  }
837
837
  if (event.type !== "toggle" || !event.key.startsWith("k")) return;
@@ -860,6 +860,7 @@ export class AssistantMessageComponent extends Container {
860
860
  return entry.node;
861
861
  };
862
862
  const children: NativeChild[] = [];
863
+ let answered = false;
863
864
  if (message) {
864
865
  const live = !this.#transcriptBlockFinalized && this.#lastUpdateTransient;
865
866
  let tailIndex = -1;
@@ -876,6 +877,7 @@ export class AssistantMessageComponent extends Container {
876
877
  const content = message.content[index]!;
877
878
  const streaming = live && index === tailIndex;
878
879
  if (content.type === "text" && canonicalizeMessage(content.text)) {
880
+ answered = true;
879
881
  const source = content.text.trim();
880
882
  // The terminal renders this source itself and would resolve a relative
881
883
  // link against its own idea of the cwd: hand it the session-resolved
@@ -983,16 +985,29 @@ export class AssistantMessageComponent extends Container {
983
985
  }
984
986
  if (this.#turnUsage) {
985
987
  const usage = formatTurnUsage(this.#turnUsage);
988
+ const line: NativeChild[] = [];
989
+ // Led by Copy and a dot: `⧉ · 12.4s · 19K tok this turn`.
990
+ if (answered && hasTranscriptActions()) {
991
+ line.push(
992
+ // `copy-answer`, not Tern's local `copy` (that would copy the icon's empty label).
993
+ node(
994
+ "icon",
995
+ {
996
+ name: "copy",
997
+ role: "omp.turn.copy",
998
+ title: "Copy answer",
999
+ aria: "Copy answer",
1000
+ actions: { click: "copy-answer" },
1001
+ },
1002
+ undefined,
1003
+ "copy",
1004
+ ),
1005
+ node("text", { text: "·" }, undefined, "dot"),
1006
+ );
1007
+ }
1008
+ line.push(node("text", { text: usage.text }, undefined, "text"));
986
1009
  children.push(
987
- node(
988
- "row",
989
- { role: "omp.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
- ],
994
- "turn-usage",
995
- ),
1010
+ node("row", { role: "omp.turn.usage", gap: "xs", align: "center", title: usage.title }, line, "turn-usage"),
996
1011
  );
997
1012
  }
998
1013
  return col(children, { role: this.#rewound ? "omp.assistant.rewound" : "omp.assistant" });
@@ -1081,11 +1096,24 @@ export class AssistantMessageComponent extends Container {
1081
1096
  return card({ role: "omp.error", tone: "error", key: "error" }, children);
1082
1097
  }
1083
1098
 
1084
- /** Error frame action clicks: omp's own retry, clipboard and model-picker paths. */
1085
- #handleErrorAction(act: string): void {
1099
+ /** Control clicks (the error frame's actions, the turn footer's Copy): omp's own retry, clipboard and model-picker paths. */
1100
+ #handleAction(act: string): void {
1086
1101
  if (act === "retry") runTranscriptAction({ act: "retry" });
1087
1102
  else if (act === "switch-model") runTranscriptAction({ act: "switch-model" });
1088
1103
  else if (act === "copy-error" && this.#errorText) runTranscriptAction({ act: "copy", text: this.#errorText });
1104
+ else if (act === "copy-answer") {
1105
+ const text = this.#answerText();
1106
+ if (text) runTranscriptAction({ act: "copy", text });
1107
+ }
1108
+ }
1109
+
1110
+ /** The answer as the transcript shows it: its text blocks' markdown, a blank line apart, without thinking or tool calls. */
1111
+ #answerText(): string {
1112
+ const blocks: string[] = [];
1113
+ for (const content of this.#displayedMessage?.content ?? []) {
1114
+ if (content.type === "text" && canonicalizeMessage(content.text)) blocks.push(content.text.trim());
1115
+ }
1116
+ return blocks.join("\n\n");
1089
1117
  }
1090
1118
 
1091
1119
  /** Width-independent stable identities for the streamed leading thinking run. */
@@ -195,8 +195,9 @@ export class UserMessageComponent extends Container implements ReactionTarget, T
195
195
  /**
196
196
  * A head-less user-toned frame with the prompt as markdown under its
197
197
  * attached images (titled `#N` like their chips, opening the materialized
198
- * file on click): the fill says "you". A quiet toolbar (time, Copy, Rewind) fades in on hover when the
199
- * host handles transcript actions. The live-steering marker and the
198
+ * file on click): the fill says "you". A quiet footer like a turn's usage line
199
+ * (`⧉ ⏪ · 09:45`: Copy, Rewind, the time sent) fades in at its bottom-left on hover when
200
+ * the host handles transcript actions. The live-steering marker and the
200
201
  * agent's reaction are chips at the bottom-right, so a reaction landing
201
202
  * later updates the frame in place, even deep in scrollback.
202
203
  */
@@ -207,32 +208,44 @@ export class UserMessageComponent extends Container implements ReactionTarget, T
207
208
  this.#nativeHour12 = hour12;
208
209
  const children: NativeChild[] = [];
209
210
  if (!this.#synthetic && hasTranscriptActions()) {
210
- const tools: NativeChild[] = [];
211
+ const tools: NativeChild[] = [
212
+ // `copy-message`, not Tern's local `copy` (that would copy the icon's empty label).
213
+ node(
214
+ "icon",
215
+ {
216
+ name: "copy",
217
+ role: "omp.user.tool",
218
+ title: "Copy message",
219
+ aria: "Copy message",
220
+ actions: { click: "copy-message" },
221
+ },
222
+ undefined,
223
+ "copy",
224
+ ),
225
+ node(
226
+ "icon",
227
+ {
228
+ name: "rewind",
229
+ role: "omp.user.tool",
230
+ title: "Rewind the conversation to an earlier message",
231
+ aria: "Rewind",
232
+ actions: { click: "rewind" },
233
+ },
234
+ undefined,
235
+ "rewind",
236
+ ),
237
+ ];
211
238
  if (this.#timestamp !== undefined) {
212
239
  const at = new Date(this.#timestamp);
213
240
  tools.push(
241
+ node("text", { text: "·" }, undefined, "dot"),
214
242
  text([span(at.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12 }), "dim mono")], {
215
243
  role: "omp.user.time",
216
244
  title: at.toLocaleString([], { hour12 }),
217
245
  }),
218
246
  );
219
247
  }
220
- tools.push(
221
- // `copy-message`, not Tern's local `copy` (that would copy the label).
222
- text("Copy", {
223
- role: "omp.user.tool",
224
- actions: { click: "copy-message" },
225
- title: "Copy message",
226
- key: "copy",
227
- }),
228
- text("Rewind", {
229
- role: "omp.user.tool",
230
- actions: { click: "rewind" },
231
- title: "Rewind the conversation to an earlier message",
232
- key: "rewind",
233
- }),
234
- );
235
- children.push(node("row", { gap: "xs", role: "omp.user.tools" }, tools, "tools"));
248
+ children.push(node("row", { gap: "xs", align: "center", role: "omp.user.tools" }, tools, "tools"));
236
249
  }
237
250
  // Videos keep their chip only: the native image node decodes stills.
238
251
  const thumbs: NativeNode[] = [];
@@ -49,15 +49,15 @@ export class CountdownTimer {
49
49
 
50
50
  /**
51
51
  * The remaining time as a terminal-clocked `elapsed` node: its age is the
52
- * negative remaining time (the start lies in the future), so it counts up
53
- * toward zero and freezes there at expiry. The age is taken on the first
52
+ * negative remaining time (the start lies in the future), so it counts down
53
+ * to zero and holds there at expiry (no `stopped`: that would freeze it at
54
+ * zero from the start). The age is taken on the first
54
55
  * call after a (re)start, when the node is first written, and the node is
55
56
  * reused until {@link reset}.
56
57
  */
57
58
  describe(): NativeNode {
58
59
  this.#node ??= node("elapsed", {
59
60
  age: Math.min(0, Date.now() - this.#deadlineMs),
60
- stopped: 0,
61
61
  format: "short",
62
62
  });
63
63
  return this.#node;
@@ -96,12 +96,37 @@ interface TranscriptEntry {
96
96
  * state for emitted-row slicing but never emits another mid-stream row.
97
97
  */
98
98
  stableFrozen: boolean;
99
+ /**
100
+ * Head rows of a settled block already in native history while the rest
101
+ * stays live: pressure commits only the rows that left the screen, so no
102
+ * committed row is ever still visible above the viewport.
103
+ */
104
+ split: RowSplit | undefined;
105
+ }
106
+
107
+ /**
108
+ * A committed physical row prefix of one block. `rows` counts trimmed render
109
+ * rows at `width` (any emitted stable prefix included) and `hash` fingerprints
110
+ * them; `text` is their letters and digits in order. A settled block can still
111
+ * change (a tool pane expanding) or rewrap at another width without a replay,
112
+ * so every render re-locates the seam: the committed rows when they render
113
+ * unchanged, else by continuing `text` — per-row decoration (quote bars,
114
+ * borders, rules) is punctuation it ignores.
115
+ */
116
+ interface RowSplit {
117
+ readonly rows: number;
118
+ readonly width: number;
119
+ readonly hash: number | bigint;
120
+ readonly text: string;
121
+ /** Seam of the last render it was located in; renders are memoized per frame. */
122
+ located?: { readonly rendered: readonly string[]; readonly count: number };
99
123
  }
100
124
 
101
125
  type RetirementPolicy = "pressure" | "flush";
102
126
  type Offered =
103
127
  | { batch: HistoryBatch; kind: "append"; entry: number; emittedEnd: number }
104
- | { batch: HistoryBatch; kind: "commit"; end: number }
128
+ /** Whole blocks `[frontier, end)`, then optionally the head rows of block `end`. */
129
+ | { batch: HistoryBatch; kind: "commit"; end: number; split?: RowSplit }
105
130
  | { batch: HistoryBatch; kind: "replay" };
106
131
 
107
132
  /** Rows a progressive-append retirement offers, and the stable count they bring the head to. */
@@ -140,6 +165,24 @@ function isPlainBlank(line: string): boolean {
140
165
  return !/\S/.test(line);
141
166
  }
142
167
 
168
+ /** Letters and digits of `rows` in reading order: the content rewrapping preserves. */
169
+ function contentOf(rows: readonly string[]): string {
170
+ let text = "";
171
+ for (const row of rows) text += Bun.stripANSI(row).replace(/[^\p{L}\p{N}]/gu, "");
172
+ return text;
173
+ }
174
+
175
+ /**
176
+ * Split committing the first `rows` rows of `scrollback` at `width`. The
177
+ * fingerprint covers the scrollback bytes history receives, which can differ
178
+ * from the measured render at the same row count (a SIXEL image encodes
179
+ * synchronously instead of returning its reserved rows).
180
+ */
181
+ function rowSplit(scrollback: readonly string[], rows: number, width: number): RowSplit {
182
+ const committed = scrollback.slice(0, rows);
183
+ return { rows, width, hash: Bun.hash(committed.join("\n")), text: contentOf(committed) };
184
+ }
185
+
143
186
  /** Whether `prefix` matches `rows` byte-for-byte from the top. */
144
187
  export function isRowPrefix(prefix: readonly string[], rows: readonly string[]): boolean {
145
188
  if (prefix.length > rows.length) return false;
@@ -226,6 +269,7 @@ export class TranscriptContainer extends Container {
226
269
  stableRowCountByWidth: new Map(),
227
270
  emitted: 0,
228
271
  stableFrozen: false,
272
+ split: undefined,
229
273
  });
230
274
  // `children` is public and may be edited externally; re-check the whole
231
275
  // list once instead of trusting positions. Insert-after-anchor callers
@@ -300,13 +344,16 @@ export class TranscriptContainer extends Container {
300
344
  */
301
345
  resetStableEmission(): void {
302
346
  this.#syncEntries();
303
- if (this.#offered?.kind === "append") this.#offered = undefined;
347
+ if (this.#offered?.kind === "append" || (this.#offered?.kind === "commit" && this.#offered.split)) {
348
+ this.#offered = undefined;
349
+ }
304
350
  for (const entry of this.#entries) {
305
351
  entry.emitted = 0;
306
352
  entry.stableRows = EMPTY_STABLE_ROWS;
307
353
  entry.renderedStableByWidth = new Map();
308
354
  entry.stableRowCountByWidth = new Map();
309
355
  entry.stableFrozen = false;
356
+ entry.split = undefined;
310
357
  if (entry.mode === "appendOnly") {
311
358
  (entry.component as Component & AppendOnlyTranscriptBlock).resetTranscriptStableRows?.();
312
359
  }
@@ -329,8 +376,8 @@ export class TranscriptContainer extends Container {
329
376
  const index = this.#entries.findLastIndex(entry => entry.component === component);
330
377
  if (index < 0) return -1;
331
378
  const entry = this.#entries[index]!;
332
- if (entry.state === "committed" || entry.emitted > 0) return -1;
333
- if (this.#offered?.kind === "commit" && index < this.#offered.end) return -1;
379
+ if (entry.state === "committed" || entry.emitted > 0 || entry.split !== undefined) return -1;
380
+ if (this.#offered?.kind === "commit" && index < this.#offered.end + (this.#offered.split ? 1 : 0)) return -1;
334
381
  if (this.#offered?.kind === "append" && index === this.#offered.entry) return -1;
335
382
  return index;
336
383
  }
@@ -434,17 +481,21 @@ export class TranscriptContainer extends Container {
434
481
 
435
482
  /**
436
483
  * One block's blank-trimmed render at its full-height allocation. Inside an
437
- * open frame the first measurement of each block is replayed to later ones.
484
+ * open frame the first measurement of each block is replayed to later ones,
485
+ * leaving whatever allocation the viewport has since rendered it at.
438
486
  */
439
487
  #measuredRows(entry: TranscriptEntry, width: number): readonly string[] {
440
- this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
441
- if (this.#openFrame === undefined) return this.#renderEntry(entry, width);
488
+ if (this.#openFrame === undefined) {
489
+ this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
490
+ return this.#renderEntry(entry, width);
491
+ }
442
492
  if (this.#frameRowsWidth !== width) {
443
493
  this.#frameRows.clear();
444
494
  this.#frameRowsWidth = width;
445
495
  }
446
496
  let rows = this.#frameRows.get(entry);
447
497
  if (rows === undefined) {
498
+ this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
448
499
  rows = this.#renderEntry(entry, width);
449
500
  this.#frameRows.set(entry, rows);
450
501
  }
@@ -646,6 +697,20 @@ export class TranscriptContainer extends Container {
646
697
  rows = after.slice(before.length);
647
698
  } else if (offered.kind === "commit") {
648
699
  rows = this.#renderRange(this.#frontier, offered.end, width, true).rows;
700
+ const head = offered.split ? this.#entries[offered.end] : undefined;
701
+ if (head !== undefined && offered.split !== undefined) {
702
+ // The offer's fingerprint covers bytes of a discarded frame history
703
+ // never received (an image-budget retry demotes graphics to text),
704
+ // so the split keeps its cut and re-fingerprints the rows it now
705
+ // delivers. Only another width relocates the cut.
706
+ const start = this.#emittedRowCount(head, width);
707
+ const scrollback = renderForScrollback(() => this.#renderEntry(head, width));
708
+ const cut =
709
+ width === offered.split.width ? offered.split.rows : this.#splitRowCount(head, offered.split, width);
710
+ const end = Math.min(cut, scrollback.length);
711
+ offered.split = rowSplit(scrollback, end, width);
712
+ rows = [...rows, ...scrollback.slice(start, end)];
713
+ }
649
714
  } else {
650
715
  rows = this.#renderReplay(width);
651
716
  }
@@ -674,6 +739,7 @@ export class TranscriptContainer extends Container {
674
739
  head?.mode === "appendOnly" &&
675
740
  !head.stableFrozen &&
676
741
  head.state !== "committed" &&
742
+ head.split === undefined &&
677
743
  head.emitted < head.stableRows.length
678
744
  ? head
679
745
  : undefined;
@@ -739,9 +805,26 @@ export class TranscriptContainer extends Container {
739
805
  limit = Math.max(limit, this.#frontier + (this.#liveCount() - (MAX_LIVE_BLOCKS - 1)));
740
806
  }
741
807
  if (policy === "flush") limit = this.#entries.length;
808
+ // The block straddling the top edge commits only the rows that left the
809
+ // screen; the rest stays live, so a later shrink of the chrome or the
810
+ // tail refills the screen from live rows instead of opening a band no
811
+ // frame can fill (rows still on screen cannot return from scrollback).
812
+ let split: { index: number; rows: readonly string[]; split: RowSplit } | undefined;
813
+ if (policy === "pressure" && keep > 0 && limit === this.#frontier + keep) {
814
+ const boundary = live[keep - 1]!;
815
+ const visible = room - tailRows - (tailRows > 0 ? 1 : 0);
816
+ if (boundary.entry.state === "settled" && visible > 0) {
817
+ const offer = this.#splitOffer(boundary.entry, width, visible);
818
+ if (offer !== undefined) {
819
+ split = { index: boundary.index, ...offer };
820
+ limit = boundary.index;
821
+ }
822
+ }
823
+ }
742
824
  let end = this.#frontier;
743
825
  while (end < limit && end < this.#entries.length && this.#entries[end]!.state === "settled") end++;
744
- if (end === this.#frontier) {
826
+ if (split !== undefined && end !== split.index) split = undefined;
827
+ if (end === this.#frontier && split === undefined) {
745
828
  if (policy === "pressure") this.#notePinnedFrontier();
746
829
  return undefined;
747
830
  }
@@ -761,15 +844,35 @@ export class TranscriptContainer extends Container {
761
844
  } finally {
762
845
  popLoopPhase();
763
846
  }
847
+ if (split !== undefined && retirement.end !== split.index) split = undefined;
764
848
  const batch: HistoryBatch = {
765
849
  id: this.#nextBatchId++,
766
- rows: retirement.rows,
850
+ rows: split === undefined ? retirement.rows : [...retirement.rows, ...split.rows],
767
851
  kind: "append",
768
852
  };
769
- this.#offered = { batch, end: retirement.end, kind: "commit" };
853
+ this.#offered = { batch, end: retirement.end, kind: "commit", split: split?.split };
770
854
  return batch;
771
855
  }
772
856
 
857
+ /**
858
+ * The head rows of settled `entry` beyond its committed prefix that leave
859
+ * `visible` rows live, or undefined when nothing would remain committed
860
+ * past that prefix or the scrollback render would not line up row for row.
861
+ */
862
+ #splitOffer(
863
+ entry: TranscriptEntry,
864
+ width: number,
865
+ visible: number,
866
+ ): { rows: readonly string[]; split: RowSplit } | undefined {
867
+ const measured = this.#measuredRows(entry, width);
868
+ const start = this.#emittedRowCount(entry, width);
869
+ const total = measured.length - visible;
870
+ if (total <= start) return undefined;
871
+ const scrollback = renderForScrollback(() => this.#renderEntry(entry, width));
872
+ if (scrollback.length !== measured.length) return undefined;
873
+ return { rows: scrollback.slice(start, total), split: rowSplit(scrollback, total, width) };
874
+ }
875
+
773
876
  /** Acknowledges exactly the most recently offered append, commit, or replay transaction. */
774
877
  acknowledgeFinalizedBatch(id: number): void {
775
878
  const offered = this.#offered;
@@ -786,6 +889,8 @@ export class TranscriptContainer extends Container {
786
889
  this.#retireEntry(this.#entries[index]!);
787
890
  }
788
891
  this.#frontier = offered.end;
892
+ const head = offered.split ? this.#entries[offered.end] : undefined;
893
+ if (head !== undefined) head.split = offered.split;
789
894
  } else {
790
895
  this.#releaseCommittedRenderCaches();
791
896
  }
@@ -928,14 +1033,32 @@ export class TranscriptContainer extends Container {
928
1033
  }
929
1034
 
930
1035
  /**
931
- * Length-only variant of `#renderStablePrefix`: answers the projected
932
- * emitted row count without re-rendering the prefix. The container only
933
- * needs the length for slicing; the render call it replaced existed
934
- * purely to read `.length` off the result.
1036
+ * Rows of `entry` at `width` already in native history or in the
1037
+ * unacknowledged offer: the row split when one is pending or committed,
1038
+ * else its emitted stable prefix.
935
1039
  */
936
1040
  #projectedEmittedRowCount(entry: TranscriptEntry, index: number, width: number): number {
937
1041
  const offered = this.#offered;
938
- const count = offered?.kind === "append" && offered.entry === index ? offered.emittedEnd : entry.emitted;
1042
+ if (offered?.kind === "commit" && offered.split !== undefined && offered.end === index) {
1043
+ return this.#splitRowCount(entry, offered.split, width);
1044
+ }
1045
+ if (offered?.kind === "append" && offered.entry === index && entry.split === undefined) {
1046
+ return this.#stablePrefixRowCount(entry, offered.emittedEnd, width);
1047
+ }
1048
+ return this.#emittedRowCount(entry, width);
1049
+ }
1050
+
1051
+ /** Rows of `entry` at `width` already acknowledged into native history. */
1052
+ #emittedRowCount(entry: TranscriptEntry, width: number): number {
1053
+ if (entry.split !== undefined) return this.#splitRowCount(entry, entry.split, width);
1054
+ return this.#stablePrefixRowCount(entry, entry.emitted, width);
1055
+ }
1056
+
1057
+ /**
1058
+ * Length-only variant of `#renderStablePrefix`: answers the emitted row
1059
+ * count without re-rendering the prefix when this width already recorded it.
1060
+ */
1061
+ #stablePrefixRowCount(entry: TranscriptEntry, count: number, width: number): number {
939
1062
  if (count === 0) return 0;
940
1063
  const perCount = entry.stableRowCountByWidth.get(width);
941
1064
  const memo = perCount?.get(Math.min(count, entry.stableRows.length));
@@ -943,6 +1066,39 @@ export class TranscriptContainer extends Container {
943
1066
  return this.#renderStablePrefix(entry, count, width).length;
944
1067
  }
945
1068
 
1069
+ /**
1070
+ * Rows of `entry`'s current render at `width` that `split` committed: all
1071
+ * of them while they render unchanged, else the longest prefix whose
1072
+ * content continues the committed text exactly. A row the seam falls
1073
+ * inside, a row the block changed, a row with no content (a rule or other
1074
+ * punctuation the block revealed), or one whose decoration reads as
1075
+ * content stays live: it can repeat committed text but never drop any.
1076
+ */
1077
+ #splitRowCount(entry: TranscriptEntry, split: RowSplit, width: number): number {
1078
+ const rendered = this.#measuredRows(entry, width);
1079
+ if (split.located?.rendered === rendered) return split.located.count;
1080
+ let count = 0;
1081
+ if (
1082
+ width === split.width &&
1083
+ rendered.length >= split.rows &&
1084
+ Bun.hash(rendered.slice(0, split.rows).join("\n")) === split.hash
1085
+ ) {
1086
+ count = split.rows;
1087
+ } else {
1088
+ let consumed = 0;
1089
+ while (count < rendered.length && consumed < split.text.length) {
1090
+ const row = contentOf([rendered[count]!]);
1091
+ // An empty-content row matches at any offset, so the walk cannot
1092
+ // tell it apart from a revealed row history never received.
1093
+ if (row.length === 0 || !split.text.startsWith(row, consumed)) break;
1094
+ consumed += row.length;
1095
+ count++;
1096
+ }
1097
+ }
1098
+ split.located = { rendered, count };
1099
+ return count;
1100
+ }
1101
+
946
1102
  /**
947
1103
  * Size a progressive-append batch from the stable renders `#renderEntry`
948
1104
  * recorded at this width, without rendering any prefix. Each recorded count
@@ -950,7 +1106,8 @@ export class TranscriptContainer extends Container {
950
1106
  * `renderedStableByWidth` holds the newest, so each count's rows are a
951
1107
  * prefix of it and the batch is one slice. Undefined when a count the walk
952
1108
  * needs was never recorded here (published between renders, or before a
953
- * resize); the rendered walk then decides.
1109
+ * resize); the rendered walk then decides. Like the rendered walk, it never
1110
+ * passes `overflow`: a prefix reaching onto the screen waits.
954
1111
  */
955
1112
  #measuredAppendBatch(entry: TranscriptEntry, width: number, overflow: number): AppendBatch | undefined {
956
1113
  const counts = entry.stableRowCountByWidth.get(width);
@@ -961,7 +1118,7 @@ export class TranscriptContainer extends Container {
961
1118
  if (start === undefined) return undefined;
962
1119
  let emittedEnd = entry.emitted;
963
1120
  let end = start;
964
- while (emittedEnd < target && end - start < overflow) {
1121
+ while (emittedEnd < target) {
965
1122
  const next = counts.get(emittedEnd + 1);
966
1123
  if (next === undefined) return undefined;
967
1124
  if (next <= start) {
@@ -970,6 +1127,7 @@ export class TranscriptContainer extends Container {
970
1127
  }
971
1128
  break;
972
1129
  }
1130
+ if (next - start > overflow) break;
973
1131
  end = next;
974
1132
  emittedEnd += 1;
975
1133
  }
@@ -978,15 +1136,18 @@ export class TranscriptContainer extends Container {
978
1136
 
979
1137
  /**
980
1138
  * Size a progressive-append batch by rendering each further stable prefix:
981
- * extend the emitted prefix until its new rows cover `overflow`, stopping
982
- * at the first prefix that adds no row or stops extending the emitted one
983
- * (freezing the block when that is the very next prefix).
1139
+ * extend the emitted prefix while its new rows stay within `overflow` — the
1140
+ * rows that left the screen — stopping at the first prefix that would reach
1141
+ * onto it, adds no row, or stops extending the emitted one (freezing the
1142
+ * block when that is the very next prefix). A prefix still partly on screen
1143
+ * stays live: committing it would strand those rows above the live tail,
1144
+ * where a later shrink of the chrome below opens a band no frame can fill.
984
1145
  */
985
1146
  #renderedAppendBatch(entry: TranscriptEntry, width: number, overflow: number): AppendBatch {
986
1147
  const before = this.#renderStablePrefix(entry, entry.emitted, width);
987
1148
  let emittedEnd = entry.emitted;
988
1149
  let after = before;
989
- while (emittedEnd < entry.stableRows.length && after.length - before.length < overflow) {
1150
+ while (emittedEnd < entry.stableRows.length) {
990
1151
  const next = this.#renderStablePrefix(entry, emittedEnd + 1, width);
991
1152
  if (!isRowPrefix(before, next) || next.length === before.length) {
992
1153
  if (emittedEnd === entry.emitted) {
@@ -994,6 +1155,7 @@ export class TranscriptContainer extends Container {
994
1155
  }
995
1156
  break;
996
1157
  }
1158
+ if (next.length - before.length > overflow) break;
997
1159
  after = next;
998
1160
  emittedEnd += 1;
999
1161
  }
@@ -1055,7 +1217,7 @@ export class TranscriptContainer extends Container {
1055
1217
  const rendered = renderForScrollback(() =>
1056
1218
  index === start ? this.#renderEntry(entry, width) : trimBlankEdges(entry.component.render(width)),
1057
1219
  );
1058
- const emittedRows = index === start ? this.#renderStablePrefix(entry, entry.emitted, width).length : 0;
1220
+ const emittedRows = index === start ? this.#emittedRowCount(entry, width) : 0;
1059
1221
  const block = rendered.slice(emittedRows);
1060
1222
  reached = index + 1;
1061
1223
  if (block.length > 0) {
@@ -1071,7 +1233,11 @@ export class TranscriptContainer extends Container {
1071
1233
  #renderReplay(width: number): readonly string[] {
1072
1234
  const rows = Array.from(this.#renderRange(0, this.#frontier, width, true).rows);
1073
1235
  const head = this.#entries[this.#frontier];
1074
- if (head?.mode === "appendOnly" && head.emitted > 0) {
1236
+ if (head?.split !== undefined) {
1237
+ this.#setAllocation(head.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
1238
+ const committed = this.#splitRowCount(head, head.split, width);
1239
+ rows.push(...renderForScrollback(() => this.#renderEntry(head, width)).slice(0, committed));
1240
+ } else if (head?.mode === "appendOnly" && head.emitted > 0) {
1075
1241
  this.#setAllocation(head.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
1076
1242
  this.#renderEntry(head, width);
1077
1243
  rows.push(...this.#renderStablePrefix(head, head.emitted, width));
@@ -1082,10 +1248,15 @@ export class TranscriptContainer extends Container {
1082
1248
  #completeFullyEmittedHeads(width: number): void {
1083
1249
  while (this.#frontier < this.#entries.length) {
1084
1250
  const entry = this.#entries[this.#frontier]!;
1085
- if (entry.mode !== "appendOnly" || entry.state !== "settled") return;
1086
- const rendered = this.#measuredRows(entry, width);
1087
- if (entry.emitted !== entry.stableRows.length) return;
1088
- if (this.#renderStablePrefix(entry, entry.emitted, width).length !== rendered.length) return;
1251
+ if (entry.state !== "settled") return;
1252
+ if (entry.split !== undefined) {
1253
+ if (this.#splitRowCount(entry, entry.split, width) < this.#measuredRows(entry, width).length) return;
1254
+ } else {
1255
+ if (entry.mode !== "appendOnly") return;
1256
+ const rendered = this.#measuredRows(entry, width);
1257
+ if (entry.emitted !== entry.stableRows.length) return;
1258
+ if (this.#renderStablePrefix(entry, entry.emitted, width).length !== rendered.length) return;
1259
+ }
1089
1260
  this.#retireEntry(entry);
1090
1261
  this.#frontier++;
1091
1262
  }
@@ -1094,6 +1265,7 @@ export class TranscriptContainer extends Container {
1094
1265
  #retireEntry(entry: TranscriptEntry): void {
1095
1266
  entry.state = "committed";
1096
1267
  entry.emitted = 0;
1268
+ entry.split = undefined;
1097
1269
  entry.stableRows = EMPTY_STABLE_ROWS;
1098
1270
  entry.renderedStableByWidth = new Map();
1099
1271
  entry.stableRowCountByWidth = new Map();
@@ -1124,7 +1296,8 @@ export class TranscriptContainer extends Container {
1124
1296
 
1125
1297
  #startReplay(): void {
1126
1298
  const head = this.#entries[this.#frontier];
1127
- this.#replayPending = this.#frontier > 0 || (head?.mode === "appendOnly" && head.emitted > 0);
1299
+ this.#replayPending =
1300
+ this.#frontier > 0 || head?.split !== undefined || (head?.mode === "appendOnly" && head.emitted > 0);
1128
1301
  this.#replayRequested = false;
1129
1302
  }
1130
1303
 
@@ -1295,6 +1468,7 @@ export class TranscriptContainer extends Container {
1295
1468
  stableRowCountByWidth: new Map(),
1296
1469
  emitted: 0,
1297
1470
  stableFrozen: false,
1471
+ split: undefined,
1298
1472
  },
1299
1473
  );
1300
1474
  this.#frontier = this.#entries.findIndex(entry => entry.state !== "committed");