@oh-my-pi/pi-tui 18.9.2 → 18.10.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.10.0] - 2026-10-12
6
+
7
+ ### Breaking Changes
8
+
9
+ - Removed `RuntimeChildrenOptions.transient` and `Component.retireDisplacedTranscript`: the composer now bills all chrome below the transcript at the smallest height it has rendered, so no root opts in or out ([#15405](https://github.com/can1357/oh-my-pi/pull/15405) by [@H4vC](https://github.com/H4vC))
10
+
11
+ ### Added
12
+
13
+ - Added the `app.ask.hide` keybinding (default `Alt+H`) and Hide/Show actions on `AskDialogComponent`, which collapse the dialog to a single line while its question stays pending ([#15400](https://github.com/can1357/oh-my-pi/pull/15400) by [@H4vC](https://github.com/H4vC))
14
+
15
+ ### Changed
16
+
17
+ - The `OMP_TUI_DEBUG` control socket now uses the native IPC transport: it is owner-only and no longer replaces a socket another live process is serving.
18
+ - Transcript rows pushed aside by an open dialog, report or HUD now reach scrollback when it closes or newer output pushes them out, instead of while it is open ([#15405](https://github.com/can1357/oh-my-pi/pull/15405) by [@H4vC](https://github.com/H4vC))
19
+ - A transcript block straddling the top of the screen now sends only its off-screen rows to scrollback; the rest stays live ([#15405](https://github.com/can1357/oh-my-pi/pull/15405) by [@H4vC](https://github.com/H4vC))
20
+
21
+ ### Fixed
22
+
23
+ - Fixed blank rows opening under the editor after an ask or other inline dialog, command report or todo/loader HUD closed in a session that had filled the screen; the screen now refills with the transcript ([#15405](https://github.com/can1357/oh-my-pi/pull/15405) by [@H4vC](https://github.com/H4vC))
24
+ - Fixed the editor rising above blank rows when a live tool preview collapses under scrolled-off output; it now stays on the bottom row ([#15405](https://github.com/can1357/oh-my-pi/pull/15405) by [@H4vC](https://github.com/H4vC))
25
+ - Fixed blank rows opening inside a streamed answer when the working loader went away at the end of a turn ([#15405](https://github.com/can1357/oh-my-pi/pull/15405) by [@H4vC](https://github.com/H4vC))
26
+ - Fixed a split transcript block dropping punctuation-only rows it reveals after the split (such as a tool pane's rule line on expand); the seam now stops before them instead of treating them as already committed ([#15420](https://github.com/can1357/oh-my-pi/pull/15420) by [@H4vC](https://github.com/H4vC))
27
+ - Fixed a split transcript block fingerprinting measured rows instead of the bytes committed to scrollback, which could duplicate a SIXEL image prefix once its pending encode landed ([#15420](https://github.com/can1357/oh-my-pi/pull/15420) by [@H4vC](https://github.com/H4vC))
28
+ - Fixed a transcript block straddling the top of the screen losing its off-screen rows when the inline-image limit demoted its images to text in the same frame; those rows now reach scrollback as text
29
+
30
+ ### Removed
31
+
32
+ - Removed `Composer.pinInputToBottom()`; the composer keeps the input on the bottom row by itself ([#15405](https://github.com/can1357/oh-my-pi/pull/15405) by [@H4vC](https://github.com/H4vC))
33
+
5
34
  ## [18.9.2] - 2026-10-11
6
35
 
7
36
  ### Added
@@ -17,6 +17,7 @@ interface AppKeybindings {
17
17
  "app.model.selectTemporary": true;
18
18
  "app.tools.expand": true;
19
19
  "app.tools.toggleVisibility": true;
20
+ "app.ask.hide": true;
20
21
  "app.editor.external": true;
21
22
  "app.message.followUp": true;
22
23
  "app.retry": true;
@@ -238,6 +239,10 @@ export declare const KEYBINDINGS: {
238
239
  readonly defaultKeys: "ctrl+shift+o";
239
240
  readonly description: "Show or hide tool activity";
240
241
  };
242
+ readonly "app.ask.hide": {
243
+ readonly defaultKeys: "alt+h";
244
+ readonly description: "Hide or show a pending ask dialog";
245
+ };
241
246
  readonly "app.editor.external": {
242
247
  readonly defaultKeys: "ctrl+g";
243
248
  readonly description: "Open external editor";
@@ -22,6 +22,8 @@ export interface TuiDebugTree {
22
22
  export declare class TuiDebugServer {
23
23
  #private;
24
24
  constructor(tui: TUI, path: string);
25
+ /** Resolves true once the socket accepts connections, false if listen failed or the server stopped first. */
26
+ get ready(): Promise<boolean>;
25
27
  start(): void;
26
28
  stop(): void;
27
29
  }
@@ -88,7 +88,6 @@ export declare class AskDialogComponent implements Component {
88
88
  #private;
89
89
  private readonly callbacks;
90
90
  private readonly options;
91
- readonly retireDisplacedTranscript = true;
92
91
  constructor(questions: ExtensionAskDialogQuestion[], callbacks: AskDialogCallbacks, options?: AskDialogOptions);
93
92
  invalidate(): void;
94
93
  dispose(): void;
@@ -112,7 +111,8 @@ export declare class AskDialogComponent implements Component {
112
111
  * Pointer actions: a tab click switches to it; a click on an answer row is
113
112
  * Enter on it (Space for a multi-select option, so it toggles; its
114
113
  * double-click `activate` is Enter); the action bar's Submit, Note and Skip
115
- * run exactly what Enter, `n` and Esc run on the highlighted row.
114
+ * run exactly what Enter, `n` and Esc run on the highlighted row; Hide/Show
115
+ * collapse and restore the dialog.
116
116
  */
117
117
  handleNativeEvent(event: NativeUiEvent): void;
118
118
  }
@@ -50,18 +50,8 @@ export interface ComposerOptions {
50
50
  readonly exit?: (code: number) => void;
51
51
  readonly now?: () => number;
52
52
  }
53
- /** How {@link Composer.setRuntimeChildren} bills below-transcript roots against transcript retirement. */
53
+ /** How {@link Composer.setRuntimeChildren} lays out the roots below the transcript. */
54
54
  export interface RuntimeChildrenOptions {
55
- /**
56
- * Below-transcript roots whose growth is momentary (inline dialogs, a tall
57
- * multi-line editor). They are billed at their smallest height so expansion
58
- * clips the live tail instead of retiring rows a later shrink could not
59
- * reclaim (#11007). Every other root is billed at its current height, so
60
- * settled rows it displaces retire to native scrollback. Decision panels
61
- * declaring `retireDisplacedTranscript` opt out of this floor even when
62
- * mounted as a child of a transient editor container.
63
- */
64
- readonly transient?: readonly Component[];
65
55
  /**
66
56
  * Dock order on a TSP terminal (no status strip; the composer carries its
67
57
  * facts): the native dock stacks HUD pills, the working row and queued messages over
@@ -123,15 +113,6 @@ export declare class Composer implements TerminalFrameProvider, NativeSurfacePro
123
113
  * the screen rows left above them; `undefined` before `root` was laid out.
124
114
  */
125
115
  rowsBelow(root: Component): number | undefined;
126
- /**
127
- * Keep the input on the bottom row while the live rows cannot fill the
128
- * screen, as after an inline decision panel closes. A tall block that just
129
- * left the chrome above the editor (a command report) may have scrolled
130
- * rows into native history that cannot be pulled back; without the pin the
131
- * editor would jump up to where the shorter frame now ends. The pin lifts
132
- * once live rows fill the screen again.
133
- */
134
- pinInputToBottom(): void;
135
116
  /** Compose the bounded mutable viewport and the next ordered history append. */
136
117
  renderFrame(viewport: ViewportSize): TerminalFramePlan;
137
118
  /**
@@ -115,9 +115,6 @@ export interface Component {
115
115
  * last time.
116
116
  */
117
117
  render(width: number): readonly string[];
118
- /** Inline decision panels must keep displaced settled transcript rows in
119
- * native scrollback even when mounted inside a transient editor container. */
120
- readonly retireDisplacedTranscript?: boolean;
121
118
  /**
122
119
  * Describe the component semantically for a Tern Surface Protocol
123
120
  * terminal (see `native/node.ts`). Called instead of `render()` when the
@@ -440,6 +437,16 @@ export declare class TUI extends Container {
440
437
  top: number;
441
438
  length: number;
442
439
  };
440
+ /**
441
+ * Rows of native history the last paint left on screen above a mutable
442
+ * viewport that reached the bottom row, or `undefined` when that paint left
443
+ * rows free below it (or none happened, or a destructive reset will repaint
444
+ * from row zero). Rows above a bottom-reaching viewport may already sit in
445
+ * native scrollback, which a later shrink cannot pull back down.
446
+ */
447
+ getFilledHistoryRows(): number | undefined;
448
+ /** Whether native history holds rows the frame provider committed since the last destructive reset. */
449
+ hasProviderHistory(): boolean;
443
450
  /**
444
451
  * Probe for opt-in normal-buffer click capture. The provider is read every
445
452
  * frame; while it returns true (and no fullscreen overlay owns the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.9.2",
4
+ "version": "18.10.0",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -41,14 +41,14 @@
41
41
  "gen:glyphs": "bun scripts/gen-glyphs.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@oh-my-pi/omptype": "18.9.2",
45
- "@oh-my-pi/pi-agent-core": "18.9.2",
46
- "@oh-my-pi/pi-ai": "18.9.2",
47
- "@oh-my-pi/pi-catalog": "18.9.2",
48
- "@oh-my-pi/pi-natives": "18.9.2",
49
- "@oh-my-pi/pi-utils": "18.9.2",
50
- "@oh-my-pi/pi-wire": "18.9.2",
51
- "@oh-my-pi/snapcompact": "18.9.2"
44
+ "@oh-my-pi/omptype": "18.10.0",
45
+ "@oh-my-pi/pi-agent-core": "18.10.0",
46
+ "@oh-my-pi/pi-ai": "18.10.0",
47
+ "@oh-my-pi/pi-catalog": "18.10.0",
48
+ "@oh-my-pi/pi-natives": "18.10.0",
49
+ "@oh-my-pi/pi-utils": "18.10.0",
50
+ "@oh-my-pi/pi-wire": "18.10.0",
51
+ "@oh-my-pi/snapcompact": "18.10.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -37,6 +37,7 @@ interface AppKeybindings {
37
37
  "app.model.selectTemporary": true;
38
38
  "app.tools.expand": true;
39
39
  "app.tools.toggleVisibility": true;
40
+ "app.ask.hide": true;
40
41
  "app.editor.external": true;
41
42
  "app.message.followUp": true;
42
43
  "app.retry": true;
@@ -138,6 +139,10 @@ export const KEYBINDINGS = {
138
139
  defaultKeys: "ctrl+shift+o",
139
140
  description: "Show or hide tool activity",
140
141
  },
142
+ "app.ask.hide": {
143
+ defaultKeys: "alt+h",
144
+ description: "Hide or show a pending ask dialog",
145
+ },
141
146
  "app.editor.external": {
142
147
  defaultKeys: "ctrl+g",
143
148
  description: "Open external editor",
@@ -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");