@oh-my-pi/pi-tui 16.5.0 → 16.5.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.5.1] - 2026-07-14
6
+
7
+ ### Fixed
8
+
9
+ - Optimized the Markdown rendering cache to prevent large documents from indefinitely occupying cache slots, improving memory usage and performance ([#4820](https://github.com/can1357/oh-my-pi/issues/4820)).
10
+ - Fixed viewport corruption on macOS caused by unmanaged stderr writes (such as libmalloc or framework diagnostics) while the terminal is active.
11
+ - Fixed an issue where streamed diff code fences retained unhighlighted rows in native scrollback when long transient blocks left the viewport before finalization ([#5126](https://github.com/can1357/oh-my-pi/issues/5126)).
12
+ - Fixed native Windows Terminal sessions failing to detect mid-run light/dark theme changes when Mode 2031 appearance notifications are unavailable ([#5091](https://github.com/can1357/oh-my-pi/issues/5091)).
13
+ - Hid empty HTML comment separators in Markdown-rendered TUI output instead of displaying them literally ([#4911](https://github.com/can1357/oh-my-pi/issues/4911)).
14
+
5
15
  ## [16.5.0] - 2026-07-13
6
16
 
7
17
  ### Changed
@@ -97,6 +97,13 @@ export interface NativeScrollbackLiveRegion {
97
97
  export interface NativeScrollbackCommittedRows {
98
98
  setNativeScrollbackCommittedRows(rows: number): void;
99
99
  }
100
+ /**
101
+ * A component that discards rows after they enter native scrollback implements
102
+ * this hook so a destructive full replay can rehydrate its complete frame.
103
+ */
104
+ export interface NativeScrollbackReplay {
105
+ prepareNativeScrollbackReplay(): void;
106
+ }
100
107
  /**
101
108
  * Opt-in stability report for components that mutate their returned render
102
109
  * array in place across frames (instead of returning a fresh array per
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "16.5.0",
4
+ "version": "16.5.1",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "16.5.0",
41
- "@oh-my-pi/pi-utils": "16.5.0",
40
+ "@oh-my-pi/pi-natives": "16.5.1",
41
+ "@oh-my-pi/pi-utils": "16.5.1",
42
42
  "lru-cache": "11.5.2",
43
43
  "marked": "^18.0.6"
44
44
  },
@@ -86,6 +86,7 @@ function createHtmlNormalizationState(): HtmlNormalizationState {
86
86
  return { lists: [], openItems: [], itemHasContent: [] };
87
87
  }
88
88
 
89
+ const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
89
90
  const HTML_TAG_REGEX = /<\/?(?:br|p|ol|ul|li|span|text|code|hr|blockquote)\b(?:\s[^>]*)?\s*\/?>/gi;
90
91
  // Block-level HTML that needs structural (not just textual) rendering: standalone
91
92
  // `<hr>` becomes a rule and balanced `<blockquote>…</blockquote>` renders with
@@ -136,11 +137,12 @@ function normalizeHtmlForTerminal(
136
137
  let output = "";
137
138
  let lastIndex = 0;
138
139
  let inCode = false;
140
+ const withoutComments = raw.replace(HTML_COMMENT_REGEX, "");
139
141
 
140
- for (const match of raw.matchAll(HTML_TAG_REGEX)) {
142
+ for (const match of withoutComments.matchAll(HTML_TAG_REGEX)) {
141
143
  const tag = match[0];
142
144
  const index = match.index ?? 0;
143
- const textBeforeTag = normalizeHtmlEntitiesForTerminal(raw.slice(lastIndex, index));
145
+ const textBeforeTag = normalizeHtmlEntitiesForTerminal(withoutComments.slice(lastIndex, index));
144
146
  const name = htmlTagName(tag);
145
147
  // Most tags handled here are block-level. Inline contexts — span, text, and
146
148
  // the content inside a `<code>` run — keep their surrounding whitespace
@@ -238,7 +240,7 @@ function normalizeHtmlForTerminal(
238
240
  }
239
241
  }
240
242
 
241
- const remainingText = normalizeHtmlEntitiesForTerminal(raw.slice(lastIndex));
243
+ const remainingText = normalizeHtmlEntitiesForTerminal(withoutComments.slice(lastIndex));
242
244
  markCurrentHtmlItemContent(state, remainingText);
243
245
  return output + (inCode && codeHook ? codeHook(remainingText) : remainingText);
244
246
  }
@@ -602,8 +604,21 @@ markdownParser.use({ extensions: [customHrExtension, mathBlockExtension, mathEnv
602
604
  // (Rust FFI) work for content/layout combinations already seen this session.
603
605
 
604
606
  const RENDER_CACHE_MAX = 256; // sane cap: ~256 distinct message × width combos
607
+ const RENDER_CACHE_MAX_SIZE = 512 * 1024;
608
+ const RENDER_CACHE_MAX_ENTRY_SIZE = 32 * 1024;
605
609
  const EMPTY_RENDER_LINES: readonly string[] = [];
606
- const renderCache = new LRUCache<string, readonly string[]>({ max: RENDER_CACHE_MAX });
610
+ const renderCache = new LRUCache<string, readonly string[]>({
611
+ max: RENDER_CACHE_MAX,
612
+ maxSize: RENDER_CACHE_MAX_SIZE,
613
+ maxEntrySize: RENDER_CACHE_MAX_ENTRY_SIZE,
614
+ sizeCalculation: renderedLinesCacheSize,
615
+ });
616
+
617
+ function renderedLinesCacheSize(lines: readonly string[]): number {
618
+ let size = lines.length;
619
+ for (let i = 0; i < lines.length; i++) size += lines[i]!.length;
620
+ return Math.max(1, size);
621
+ }
607
622
 
608
623
  // A reference-link definition (`[label]: dest`) resolves across the whole
609
624
  // document, so a split lex cannot reproduce it — disable the streaming fast path
@@ -937,6 +952,11 @@ interface StreamPrefixLineCache extends RenderSignature {
937
952
  tokenCount: number;
938
953
  lines: readonly string[];
939
954
  }
955
+ interface StreamingDiffLineCache extends RenderSignature {
956
+ lang: string | undefined;
957
+ text: string;
958
+ lines: readonly string[];
959
+ }
940
960
 
941
961
  export class Markdown implements Component {
942
962
  #text: string;
@@ -979,8 +999,12 @@ export class Markdown implements Component {
979
999
  // True while #renderStreamingContentLines renders the frozen token range:
980
1000
  // frozen code blocks highlight even in transient mode so their bytes match
981
1001
  // the finalized render (they render once into the prefix line cache, so
982
- // the FFI cost is amortized); the volatile tail stays unhighlighted.
1002
+ // the FFI cost is amortized). The volatile tail normally stays
1003
+ // unhighlighted; streaming diff fences line-highlight completed rows so
1004
+ // semantic colors reach native scrollback before rows leave the viewport.
983
1005
  #renderingFrozenPrefix = false;
1006
+ #streamingDiffLineCache?: StreamingDiffLineCache;
1007
+ #activeRenderSignature?: RenderSignature;
984
1008
 
985
1009
  #ignoreTight = false;
986
1010
 
@@ -1190,9 +1214,15 @@ export class Markdown implements Component {
1190
1214
 
1191
1215
  // Parse markdown to HTML-like tokens
1192
1216
  const tokens = this.#lexTokens(normalizedText);
1193
- const contentLines = this.transientRenderCache
1194
- ? this.#renderStreamingContentLines(tokens, normalizedText, signature, contentWidth)
1195
- : this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
1217
+ let contentLines: string[];
1218
+ this.#activeRenderSignature = signature;
1219
+ try {
1220
+ contentLines = this.transientRenderCache
1221
+ ? this.#renderStreamingContentLines(tokens, normalizedText, signature, contentWidth)
1222
+ : this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
1223
+ } finally {
1224
+ this.#activeRenderSignature = undefined;
1225
+ }
1196
1226
  const emptyLines = this.#renderEmptyPaddingLines(signature);
1197
1227
 
1198
1228
  // Combine top padding, content, and bottom padding
@@ -1386,6 +1416,122 @@ export class Markdown implements Component {
1386
1416
  return contentLines;
1387
1417
  }
1388
1418
 
1419
+ #renderCodeBodyLines(token: Token, codeIndent: string): string[] {
1420
+ const bodyLines: string[] = [];
1421
+ const tokenText = "text" in token && typeof token.text === "string" ? token.text : "";
1422
+ const lang = "lang" in token && typeof token.lang === "string" ? token.lang : undefined;
1423
+ const normalizedLang = lang?.toLowerCase();
1424
+ const canStreamDiff =
1425
+ this.transientRenderCache &&
1426
+ !this.#renderingFrozenPrefix &&
1427
+ this.#theme.highlightCode &&
1428
+ (normalizedLang === "diff" || normalizedLang === "patch" || normalizedLang === "udiff");
1429
+
1430
+ if (this.#theme.highlightCode && (!this.transientRenderCache || this.#renderingFrozenPrefix)) {
1431
+ const highlightedLines = this.#theme.highlightCode(tokenText, lang);
1432
+ for (const hlLine of highlightedLines) {
1433
+ bodyLines.push(`${codeIndent}${hlLine}`);
1434
+ }
1435
+ return bodyLines;
1436
+ }
1437
+
1438
+ if (canStreamDiff) {
1439
+ const closedFence = this.#codeTokenHasClosingFence(token);
1440
+ const lineEnd = tokenText.lastIndexOf("\n");
1441
+ if (closedFence || lineEnd >= 0) {
1442
+ const completedText = closedFence ? tokenText : tokenText.slice(0, lineEnd);
1443
+ for (const hlLine of this.#highlightStreamingDiffLines(completedText, lang)) {
1444
+ bodyLines.push(`${codeIndent}${hlLine}`);
1445
+ }
1446
+ if (!closedFence) {
1447
+ for (const codeLine of tokenText.slice(lineEnd + 1).split("\n")) {
1448
+ bodyLines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`);
1449
+ }
1450
+ }
1451
+ return bodyLines;
1452
+ }
1453
+ }
1454
+
1455
+ for (const codeLine of tokenText.split("\n")) {
1456
+ bodyLines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`);
1457
+ }
1458
+ return bodyLines;
1459
+ }
1460
+
1461
+ #codeTokenHasClosingFence(token: Token): boolean {
1462
+ const raw = "raw" in token && typeof token.raw === "string" ? token.raw : "";
1463
+ const firstLineEnd = raw.indexOf("\n");
1464
+ if (firstLineEnd < 0) return false;
1465
+ const openingLine = raw.slice(0, firstLineEnd);
1466
+ const openingTrimmed = openingLine.trimStart();
1467
+ const openingIndent = openingLine.length - openingTrimmed.length;
1468
+ if (openingIndent > 3) return false;
1469
+ const fenceChar = openingTrimmed.charAt(0);
1470
+ if (fenceChar !== "`" && fenceChar !== "~") return false;
1471
+ let fenceLength = 0;
1472
+ while (openingTrimmed.charAt(fenceLength) === fenceChar) fenceLength++;
1473
+ if (fenceLength < 3) return false;
1474
+
1475
+ let lineStart = firstLineEnd + 1;
1476
+ while (lineStart <= raw.length) {
1477
+ const lineEnd = raw.indexOf("\n", lineStart);
1478
+ const line = lineEnd >= 0 ? raw.slice(lineStart, lineEnd) : raw.slice(lineStart);
1479
+ const trimmed = line.trimStart();
1480
+ const indent = line.length - trimmed.length;
1481
+ let closingLength = 0;
1482
+ while (trimmed.charAt(closingLength) === fenceChar) closingLength++;
1483
+ if (indent <= 3 && closingLength >= fenceLength && trimmed.slice(closingLength).trim().length === 0) {
1484
+ return true;
1485
+ }
1486
+ if (lineEnd < 0) break;
1487
+ lineStart = lineEnd + 1;
1488
+ }
1489
+ return false;
1490
+ }
1491
+
1492
+ #highlightStreamingDiffLines(completedText: string, lang: string | undefined): readonly string[] {
1493
+ const highlightCode = this.#theme.highlightCode;
1494
+ if (!highlightCode) return [];
1495
+ const signature = this.#activeRenderSignature;
1496
+ const cache = this.#streamingDiffLineCache;
1497
+ if (
1498
+ signature &&
1499
+ cache &&
1500
+ completedText.startsWith(cache.text) &&
1501
+ (cache.text.length === completedText.length || completedText.charCodeAt(cache.text.length) === 0x0a) &&
1502
+ cache.lang === lang &&
1503
+ cache.width === signature.width &&
1504
+ cache.paddingX === signature.paddingX &&
1505
+ cache.paddingY === signature.paddingY &&
1506
+ cache.codeBlockIndent === signature.codeBlockIndent &&
1507
+ cache.themeId === signature.themeId &&
1508
+ cache.defaultTextStyleId === signature.defaultTextStyleId &&
1509
+ cache.imageProtocol === signature.imageProtocol &&
1510
+ cache.hyperlinks === signature.hyperlinks &&
1511
+ cache.textSizing === signature.textSizing &&
1512
+ cache.bgColorProbe === signature.bgColorProbe &&
1513
+ cache.headingProbe === signature.headingProbe
1514
+ ) {
1515
+ if (completedText.length === cache.text.length) return cache.lines;
1516
+ const lines = cache.lines.slice();
1517
+ const addedText = completedText.slice(cache.text.length + 1);
1518
+ for (const codeLine of addedText.split("\n")) {
1519
+ lines.push(...highlightCode(codeLine, lang));
1520
+ }
1521
+ this.#streamingDiffLineCache = { ...signature, lang, text: completedText, lines };
1522
+ return lines;
1523
+ }
1524
+
1525
+ const lines: string[] = [];
1526
+ for (const codeLine of completedText.split("\n")) {
1527
+ lines.push(...highlightCode(codeLine, lang));
1528
+ }
1529
+ if (signature) {
1530
+ this.#streamingDiffLineCache = { ...signature, lang, text: completedText, lines };
1531
+ }
1532
+ return lines;
1533
+ }
1534
+
1389
1535
  #renderEmptyPaddingLines(signature: RenderSignature): string[] {
1390
1536
  const emptyLine = padding(signature.width);
1391
1537
  const emptyLines: string[] = [];
@@ -1563,17 +1709,8 @@ export class Markdown implements Component {
1563
1709
 
1564
1710
  const codeIndent = padding(this.#codeBlockIndent);
1565
1711
  lines.push(this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
1566
- if (this.#theme.highlightCode && (!this.transientRenderCache || this.#renderingFrozenPrefix)) {
1567
- const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
1568
- for (const hlLine of highlightedLines) {
1569
- lines.push(`${codeIndent}${hlLine}`);
1570
- }
1571
- } else {
1572
- // Split code by newlines and style each line
1573
- const codeLines = token.text.split("\n");
1574
- for (const codeLine of codeLines) {
1575
- lines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`);
1576
- }
1712
+ for (const bodyLine of this.#renderCodeBodyLines(token, codeIndent)) {
1713
+ lines.push(bodyLine);
1577
1714
  }
1578
1715
  lines.push(this.#theme.codeBlockBorder("```"));
1579
1716
  if (nextTokenType && nextTokenType !== "space") {
@@ -1953,16 +2090,8 @@ export class Markdown implements Component {
1953
2090
  // Code block in list item
1954
2091
  const codeIndent = padding(this.#codeBlockIndent);
1955
2092
  lines.push({ text: this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`), nested: false });
1956
- if (this.#theme.highlightCode && (!this.transientRenderCache || this.#renderingFrozenPrefix)) {
1957
- const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
1958
- for (const hlLine of highlightedLines) {
1959
- lines.push({ text: `${codeIndent}${hlLine}`, nested: false });
1960
- }
1961
- } else {
1962
- const codeLines = token.text.split("\n");
1963
- for (const codeLine of codeLines) {
1964
- lines.push({ text: `${codeIndent}${this.#theme.codeBlock(codeLine)}`, nested: false });
1965
- }
2093
+ for (const bodyLine of this.#renderCodeBodyLines(token, codeIndent)) {
2094
+ lines.push({ text: bodyLine, nested: false });
1966
2095
  }
1967
2096
  lines.push({ text: this.#theme.codeBlockBorder("```"), nested: false });
1968
2097
  } else if (isMathToken(token)) {
package/src/terminal.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import { dlopen, FFIType, ptr } from "bun:ffi";
2
2
  import * as fs from "node:fs";
3
- import { $env, isBunTestRuntime, isTerminalHeadless, logger, postmortem } from "@oh-my-pi/pi-utils";
3
+ import {
4
+ $env,
5
+ isBunTestRuntime,
6
+ isTerminalHeadless,
7
+ logger,
8
+ postmortem,
9
+ restoreTerminalStderr,
10
+ suppressTerminalStderr,
11
+ } from "@oh-my-pi/pi-utils";
4
12
  import { setKittyProtocolActive } from "./keys";
5
13
  import { StdinBuffer } from "./stdin-buffer";
6
14
  import {
@@ -16,6 +24,7 @@ import { type HangulCompatibilityJamoWidth, setHangulCompatibilityJamoWidth } fr
16
24
  const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
17
25
  const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
18
26
  const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07";
27
+ const WINDOWS_TERMINAL_OSC11_POLL_MS = 30_000;
19
28
  // Hangul Compatibility Jamo (U+3131..=U+318E) render width is terminal-dependent:
20
29
  // Ghostty follows UAX#11 (2 cells); Terminal.app and iTerm2 render narrow (1),
21
30
  // matching the macOS platform default. Override only for terminals known to
@@ -40,6 +49,11 @@ function shouldEnableModifyOtherKeysFallback(env: NodeJS.ProcessEnv = Bun.env):
40
49
  return TERMINAL.id !== "base" && TERMINAL.id !== "trueColor";
41
50
  }
42
51
 
52
+ function shouldPollWindowsTerminalAppearance(env: NodeJS.ProcessEnv = Bun.env): boolean {
53
+ if (process.platform !== "win32") return false;
54
+ if (!env.WT_SESSION) return false;
55
+ return !env.TERM_PROGRAM || env.TERM_PROGRAM.toLowerCase() === "windows_terminal";
56
+ }
43
57
  /**
44
58
  * Maximum encoded UTF-8 bytes per `process.stdout.write` call on Windows.
45
59
  *
@@ -275,6 +289,9 @@ function createConsoleCodepageGuard(): (() => void) | null {
275
289
  */
276
290
  export function emergencyTerminalRestore(): void {
277
291
  try {
292
+ // Crash paths must surface subsequent stderr (fatal reports) on the
293
+ // real terminal; no-op when the stderr guard is inactive.
294
+ restoreTerminalStderr();
278
295
  const terminal = activeTerminal;
279
296
  if (terminal) {
280
297
  terminal.stop();
@@ -488,6 +505,7 @@ export class ProcessTerminal implements Terminal {
488
505
  #reportedColumns?: number;
489
506
  #reportedRows?: number;
490
507
  #mode2031DebounceTimer?: Timer;
508
+ #windowsTerminalAppearancePollTimer?: Timer;
491
509
  #progressTimer?: Timer;
492
510
 
493
511
  get kittyProtocolActive(): boolean {
@@ -551,6 +569,11 @@ export class ProcessTerminal implements Terminal {
551
569
  activeTerminal = this;
552
570
  terminalEverStarted = true;
553
571
 
572
+ // Keep unmanaged fd-2 writes (macOS libmalloc/framework diagnostics) off
573
+ // the viewport while we own the terminal; released in stop(). See
574
+ // stderr-guard in pi-utils (mirrors openai/codex#24459).
575
+ suppressTerminalStderr();
576
+
554
577
  // Save previous state and enable raw mode
555
578
  this.#wasRaw = process.stdin.isRaw || false;
556
579
  if (process.stdin.setRawMode) {
@@ -611,7 +634,8 @@ export class ProcessTerminal implements Terminal {
611
634
  // WezTerm) detect the appearance once at startup and pick up later OS
612
635
  // theme changes on next launch. Earlier builds polled OSC 11 every 30 s
613
636
  // here for those terminals, but each poll's OSC 11/DA1 write wiped the
614
- // user's active text selection on several of them (#3297).
637
+ // user's active text selection on several of them (#3297). Native Windows
638
+ // Terminal gets a scoped fallback after DECRQM confirms 2031 is unsupported.
615
639
 
616
640
  // Probe DEC private-mode support via DECRQM. 2026 (synchronized output)
617
641
  // gates the renderer's begin/end markers; 2048 (in-band resize) is enabled
@@ -1151,8 +1175,25 @@ export class ProcessTerminal implements Terminal {
1151
1175
  }
1152
1176
  }
1153
1177
  if (mode === 2048 && supported) this.#enableInBandResize();
1178
+ if (mode === 2031) this.#syncWindowsTerminalAppearancePolling(supported);
1154
1179
  }
1155
1180
 
1181
+ #syncWindowsTerminalAppearancePolling(mode2031Supported: boolean): void {
1182
+ if (mode2031Supported || !shouldPollWindowsTerminalAppearance() || this.#dead) {
1183
+ this.#clearWindowsTerminalAppearancePoll();
1184
+ return;
1185
+ }
1186
+ if (this.#windowsTerminalAppearancePollTimer) return;
1187
+ this.#windowsTerminalAppearancePollTimer = setInterval(() => {
1188
+ this.#queryBackgroundColor();
1189
+ }, WINDOWS_TERMINAL_OSC11_POLL_MS);
1190
+ }
1191
+
1192
+ #clearWindowsTerminalAppearancePoll(): void {
1193
+ if (!this.#windowsTerminalAppearancePollTimer) return;
1194
+ clearInterval(this.#windowsTerminalAppearancePollTimer);
1195
+ this.#windowsTerminalAppearancePollTimer = undefined;
1196
+ }
1156
1197
  #disableXtermScrollToBottomMode(mode: number): void {
1157
1198
  if (this.#xtermScrollToBottomRestoreModes.has(mode) || this.#dead) return;
1158
1199
  this.#xtermScrollToBottomRestoreModes.add(mode);
@@ -1269,6 +1310,11 @@ export class ProcessTerminal implements Terminal {
1269
1310
  activeTerminal = null;
1270
1311
  }
1271
1312
 
1313
+ // Release terminal ownership of fd 2 first so external programs,
1314
+ // suspend, and shutdown see the real stderr even if a later teardown
1315
+ // step throws.
1316
+ restoreTerminalStderr();
1317
+
1272
1318
  if (this.#clearProgressTimer()) {
1273
1319
  this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
1274
1320
  }
@@ -1305,6 +1351,7 @@ export class ProcessTerminal implements Terminal {
1305
1351
  }
1306
1352
  this.#appearanceCallbacks = [];
1307
1353
  this.#osc11Pending = false;
1354
+ this.#clearWindowsTerminalAppearancePoll();
1308
1355
  this.#osc11QueryQueued = false;
1309
1356
  this.#osc11ResponseBuffer = "";
1310
1357
  this.#osc99PendingId = undefined;
package/src/tui.ts CHANGED
@@ -211,6 +211,18 @@ export interface NativeScrollbackCommittedRows {
211
211
  setNativeScrollbackCommittedRows(rows: number): void;
212
212
  }
213
213
 
214
+ /**
215
+ * A component that discards rows after they enter native scrollback implements
216
+ * this hook so a destructive full replay can rehydrate its complete frame.
217
+ */
218
+ export interface NativeScrollbackReplay {
219
+ prepareNativeScrollbackReplay(): void;
220
+ }
221
+
222
+ function prepareNativeScrollbackReplay(component: Component): void {
223
+ (component as Component & Partial<NativeScrollbackReplay>).prepareNativeScrollbackReplay?.();
224
+ }
225
+
214
226
  function setNativeScrollbackCommittedRows(component: Component, rows: number): void {
215
227
  (component as Component & Partial<NativeScrollbackCommittedRows>).setNativeScrollbackCommittedRows?.(rows);
216
228
  }
@@ -2739,6 +2751,21 @@ export class TUI extends Container {
2739
2751
  return;
2740
2752
  }
2741
2753
 
2754
+ // A destructive replay erases native history and must receive the complete
2755
+ // component frame. Give virtualized roots one compose to rehydrate rows
2756
+ // they dropped after commit. Height-only and net-unchanged resize events
2757
+ // count too: both enter the geometry rebuild path below.
2758
+ const replayFullHistory =
2759
+ this.#hasEverRendered &&
2760
+ !resizeRepaintsInPlace() &&
2761
+ (this.#clearScrollbackOnNextRender ||
2762
+ this.#resizeEventPending ||
2763
+ (this.#previousWidth > 0 && this.#previousWidth !== width) ||
2764
+ (this.#previousHeight > 0 && this.#previousHeight !== height));
2765
+ if (replayFullHistory) {
2766
+ for (const child of this.children) prepareNativeScrollbackReplay(child);
2767
+ }
2768
+
2742
2769
  // 1. Compose the frame. Bracket the render so the image budget observes
2743
2770
  // every inline image in display order (overlays carry none). A
2744
2771
  // component-scoped frame skips the budget pass instead — it is gated on