@oh-my-pi/pi-tui 16.4.8 → 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 +20 -0
- package/dist/types/tui.d.ts +23 -3
- package/package.json +5 -5
- package/src/components/markdown.ts +158 -29
- package/src/terminal.ts +49 -2
- package/src/tui.ts +106 -34
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
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
|
+
|
|
15
|
+
## [16.5.0] - 2026-07-13
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Improved native scrollback history management by introducing an optional erase-and-replay mechanism to rebuild scrollback when mutated rows (such as finalized tool blocks or collapsed transcripts) diverge. This is now gated behind the `tui.scrollbackRebuild` setting and defaults to off.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Fixed a rendering issue where resizing the terminal during forced renders (such as tool finalization or image reconciliation) caused the entire transcript to visibly replay and flicker. Forced renders are now consolidated into a single paint once the resize settles.
|
|
24
|
+
|
|
5
25
|
## [16.4.7] - 2026-07-12
|
|
6
26
|
|
|
7
27
|
### Fixed
|
package/dist/types/tui.d.ts
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
|
|
@@ -280,9 +287,11 @@ export declare function coalesceAdjacentSgr(line: string): string;
|
|
|
280
287
|
* source just became declared-final (the block finalized / a barrier
|
|
281
288
|
* cleared). Hard-scanned in FULL with no tolerance: any content change
|
|
282
289
|
* (a pending header settling, a preview replaced by its result, a tail
|
|
283
|
-
* shifting up after a barrier removal) re-anchors so the
|
|
284
|
-
*
|
|
285
|
-
*
|
|
290
|
+
* shifting up after a barrier removal) re-anchors so the engine can
|
|
291
|
+
* erase-and-replay history with the final content exactly once (or, on
|
|
292
|
+
* ED3-unsafe multiplexers, recommit it below the frozen snapshot —
|
|
293
|
+
* duplication, never loss) instead of committing it nowhere and
|
|
294
|
+
* painting it nowhere.
|
|
286
295
|
* [finalTo, prefix.length) FROZEN visual snapshots of still-live rows —
|
|
287
296
|
* exempt: their drift is expected (a collapsing preview, a ticking
|
|
288
297
|
* progress tree) and must never spray re-anchors mid-run.
|
|
@@ -335,6 +344,17 @@ export declare class TUI extends Container {
|
|
|
335
344
|
* plus a full redraw on the frame after a new image exceeds the cap.
|
|
336
345
|
*/
|
|
337
346
|
setMaxInlineImages(cap: number): void;
|
|
347
|
+
/**
|
|
348
|
+
* Get whether scrollback divergence rebuild is enabled.
|
|
349
|
+
*/
|
|
350
|
+
getScrollbackRebuild(): boolean;
|
|
351
|
+
/**
|
|
352
|
+
* Enable or disable scrollback divergence rebuild (default off).
|
|
353
|
+
* When enabled, the engine will erase and replay the terminal's
|
|
354
|
+
* scrollback (using ED3 / alt buffer / scrollback replay) to avoid
|
|
355
|
+
* duplicate blocks when a block's final form replaces its live preview.
|
|
356
|
+
*/
|
|
357
|
+
setScrollbackRebuild(enabled: boolean): void;
|
|
338
358
|
getShowHardwareCursor(): boolean;
|
|
339
359
|
setShowHardwareCursor(enabled: boolean): void;
|
|
340
360
|
/**
|
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.
|
|
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,10 +37,10 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "16.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.
|
|
42
|
-
"lru-cache": "11.5.
|
|
43
|
-
"marked": "^18.0.
|
|
40
|
+
"@oh-my-pi/pi-natives": "16.5.1",
|
|
41
|
+
"@oh-my-pi/pi-utils": "16.5.1",
|
|
42
|
+
"lru-cache": "11.5.2",
|
|
43
|
+
"marked": "^18.0.6"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"chalk": "^5.6.2",
|
|
@@ -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
|
|
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(
|
|
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(
|
|
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[]>({
|
|
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)
|
|
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
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
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
|
-
|
|
1567
|
-
|
|
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
|
-
|
|
1957
|
-
|
|
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 {
|
|
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
|
@@ -5,11 +5,15 @@
|
|
|
5
5
|
* immutable — the tape is the terminal's visual record. Whatever scrolls
|
|
6
6
|
* above the window enters history exactly once, in order: as exact-final
|
|
7
7
|
* bytes when the component seam (`NativeScrollbackLiveRegion`) declared them
|
|
8
|
-
* final, else as a frozen snapshot of what was on screen.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* final, else as a frozen snapshot of what was on screen. When recorded
|
|
9
|
+
* history diverges from the frame (a finalized block replacing its
|
|
10
|
+
* scrolled-off live render), the engine erases and replays (ED3, `CSI 3 J`)
|
|
11
|
+
* so history holds the content exactly once — the same replay used for
|
|
12
|
+
* gestures (session replace, resize, resetDisplay). Multiplexer panes, where
|
|
13
|
+
* ED3 is unsafe, instead re-anchor and recommit below the stale fragment —
|
|
14
|
+
* duplication, never loss. The engine never probes or guesses the terminal's
|
|
15
|
+
* scroll position, and the hot path clamps over-wide lines instead of
|
|
16
|
+
* throwing. See `docs/tui-core-renderer.md`.
|
|
13
17
|
*/
|
|
14
18
|
import * as fs from "node:fs";
|
|
15
19
|
import { performance } from "node:perf_hooks";
|
|
@@ -207,6 +211,18 @@ export interface NativeScrollbackCommittedRows {
|
|
|
207
211
|
setNativeScrollbackCommittedRows(rows: number): void;
|
|
208
212
|
}
|
|
209
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
|
+
|
|
210
226
|
function setNativeScrollbackCommittedRows(component: Component, rows: number): void {
|
|
211
227
|
(component as Component & Partial<NativeScrollbackCommittedRows>).setNativeScrollbackCommittedRows?.(rows);
|
|
212
228
|
}
|
|
@@ -783,9 +799,11 @@ const RESYNC_TAIL_SAMPLES = 8;
|
|
|
783
799
|
* source just became declared-final (the block finalized / a barrier
|
|
784
800
|
* cleared). Hard-scanned in FULL with no tolerance: any content change
|
|
785
801
|
* (a pending header settling, a preview replaced by its result, a tail
|
|
786
|
-
* shifting up after a barrier removal) re-anchors so the
|
|
787
|
-
*
|
|
788
|
-
*
|
|
802
|
+
* shifting up after a barrier removal) re-anchors so the engine can
|
|
803
|
+
* erase-and-replay history with the final content exactly once (or, on
|
|
804
|
+
* ED3-unsafe multiplexers, recommit it below the frozen snapshot —
|
|
805
|
+
* duplication, never loss) instead of committing it nowhere and
|
|
806
|
+
* painting it nowhere.
|
|
789
807
|
* [finalTo, prefix.length) FROZEN visual snapshots of still-live rows —
|
|
790
808
|
* exempt: their drift is expected (a collapsing preview, a ticking
|
|
791
809
|
* progress tree) and must never spray re-anchors mid-run.
|
|
@@ -1001,6 +1019,8 @@ export class TUI extends Container {
|
|
|
1001
1019
|
#clearScrollbackOnNextRender = false;
|
|
1002
1020
|
#forceViewportRepaintOnNextRender = false;
|
|
1003
1021
|
#hasEverRendered = false;
|
|
1022
|
+
#scrollbackRebuildEnabled =
|
|
1023
|
+
Bun.env.PI_TUI_SCROLLBACK_REBUILD === "1" || Bun.env.PI_TUI_SCROLLBACK_REBUILD === "true";
|
|
1004
1024
|
// Set by the terminal resize callback; consumed by the next render. A resize
|
|
1005
1025
|
// event invalidates the committed screen even when the dimensions net out
|
|
1006
1026
|
// unchanged by render time (e.g. a 6→4→6 round trip coalesced into one frame
|
|
@@ -1290,6 +1310,23 @@ export class TUI extends Container {
|
|
|
1290
1310
|
this.#imageBudget.setCap(cap);
|
|
1291
1311
|
}
|
|
1292
1312
|
|
|
1313
|
+
/**
|
|
1314
|
+
* Get whether scrollback divergence rebuild is enabled.
|
|
1315
|
+
*/
|
|
1316
|
+
getScrollbackRebuild(): boolean {
|
|
1317
|
+
return this.#scrollbackRebuildEnabled;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* Enable or disable scrollback divergence rebuild (default off).
|
|
1322
|
+
* When enabled, the engine will erase and replay the terminal's
|
|
1323
|
+
* scrollback (using ED3 / alt buffer / scrollback replay) to avoid
|
|
1324
|
+
* duplicate blocks when a block's final form replaces its live preview.
|
|
1325
|
+
*/
|
|
1326
|
+
setScrollbackRebuild(enabled: boolean): void {
|
|
1327
|
+
this.#scrollbackRebuildEnabled = enabled;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1293
1330
|
getShowHardwareCursor(): boolean {
|
|
1294
1331
|
return this.#showHardwareCursor;
|
|
1295
1332
|
}
|
|
@@ -2696,23 +2733,39 @@ export class TUI extends Container {
|
|
|
2696
2733
|
// alternate screen to repaint the whole transcript on the normal
|
|
2697
2734
|
// screen — then the next SIGWINCH re-enters the alt screen and paints
|
|
2698
2735
|
// only the tail, so the block flashes in for one frame and vanishes.
|
|
2699
|
-
// A
|
|
2700
|
-
//
|
|
2701
|
-
//
|
|
2702
|
-
//
|
|
2703
|
-
//
|
|
2704
|
-
//
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
)
|
|
2736
|
+
// A FORCED render mid-drag (tool finalization, resetDisplay, image
|
|
2737
|
+
// reconciliation) also stays on the fast path: preempting would leave
|
|
2738
|
+
// the borrowed alternate screen and run the geometry-rebuild full paint
|
|
2739
|
+
// on the normal screen — ED3 plus an O(history) replay that visibly
|
|
2740
|
+
// scrolls the whole transcript through the viewport, once per forced
|
|
2741
|
+
// render and once more at settle. The forced intent is not lost: the
|
|
2742
|
+
// fast path consumes neither #forceViewportRepaintOnNextRender nor
|
|
2743
|
+
// #clearScrollbackOnNextRender, and the settle's authoritative
|
|
2744
|
+
// requestRender(true) honors both — same fold-into-the-settle contract
|
|
2745
|
+
// as the multiplexer resize debounce. A visible overlay composites over
|
|
2746
|
+
// the transcript and needs the whole window, so it falls through
|
|
2747
|
+
// (overlay resizes are not on the drag-cost hot path).
|
|
2748
|
+
if (this.#resizeViewportActive && this.#hasEverRendered && this.#getTopmostVisibleOverlay() === undefined) {
|
|
2711
2749
|
this.#componentRenderTargets.clear();
|
|
2712
2750
|
this.#renderResizeViewport(width, height);
|
|
2713
2751
|
return;
|
|
2714
2752
|
}
|
|
2715
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
|
+
|
|
2716
2769
|
// 1. Compose the frame. Bracket the render so the image budget observes
|
|
2717
2770
|
// every inline image in display order (overlays carry none). A
|
|
2718
2771
|
// component-scoped frame skips the budget pass instead — it is gated on
|
|
@@ -2777,8 +2830,9 @@ export class TUI extends Container {
|
|
|
2777
2830
|
// verified once (a pending header settling, a barrier clearing above a
|
|
2778
2831
|
// shifted tail); rows past the boundary are still-live frozen snapshots,
|
|
2779
2832
|
// exempt so a collapsing preview can never spray re-anchors mid-run. A
|
|
2780
|
-
// divergence re-anchors
|
|
2781
|
-
//
|
|
2833
|
+
// divergence re-anchors — feeding the divergenceRebuild erase-and-replay
|
|
2834
|
+
// below (mux fallback: recommit below the stale copy; duplication, never
|
|
2835
|
+
// loss) — instead of silently skipping rows (committed nowhere, painted
|
|
2782
2836
|
// nowhere). Skipped on geometry frames (a rewrap legitimately reflows
|
|
2783
2837
|
// every row), and skipped when the composed frame's stable prefix
|
|
2784
2838
|
// covers every verified row and no rows newly became final.
|
|
@@ -2851,7 +2905,21 @@ export class TUI extends Container {
|
|
|
2851
2905
|
const firstPaint = !this.#hasEverRendered;
|
|
2852
2906
|
const replaceRequested = this.#clearScrollbackOnNextRender;
|
|
2853
2907
|
const geometryRebuild = geometryChanged && !resizeRepaintsInPlace();
|
|
2854
|
-
|
|
2908
|
+
// Committed history no longer matches the frame: a finalized block
|
|
2909
|
+
// replaced its scrolled-off live render, or the frame collapsed into
|
|
2910
|
+
// recorded rows. Native scrollback is a render cache, not a court
|
|
2911
|
+
// record — erase and replay so history holds the content exactly once,
|
|
2912
|
+
// instead of recommitting the final form below the stale fragment
|
|
2913
|
+
// (a visibly duplicated block). Multiplexer panes cannot ED3 safely
|
|
2914
|
+
// and keep the repair-below fallback in the branches under this one.
|
|
2915
|
+
const divergenceRebuild =
|
|
2916
|
+
this.#scrollbackRebuildEnabled &&
|
|
2917
|
+
!firstPaint &&
|
|
2918
|
+
!replaceRequested &&
|
|
2919
|
+
!geometryChanged &&
|
|
2920
|
+
!isMultiplexerSession() &&
|
|
2921
|
+
(committedRowsResynced || frameLength <= this.#committedRows);
|
|
2922
|
+
const fullPaint = firstPaint || replaceRequested || geometryRebuild || divergenceRebuild;
|
|
2855
2923
|
let windowTop: number;
|
|
2856
2924
|
let chunkTo: number;
|
|
2857
2925
|
if (fullPaint) {
|
|
@@ -2864,16 +2932,17 @@ export class TUI extends Container {
|
|
|
2864
2932
|
frameLength - this.#committedRows < height &&
|
|
2865
2933
|
cursorMarkers.some(marker => marker.row >= this.#committedRows))
|
|
2866
2934
|
) {
|
|
2867
|
-
//
|
|
2868
|
-
//
|
|
2869
|
-
//
|
|
2870
|
-
//
|
|
2871
|
-
//
|
|
2872
|
-
//
|
|
2873
|
-
//
|
|
2874
|
-
//
|
|
2875
|
-
//
|
|
2876
|
-
//
|
|
2935
|
+
// Multiplexer fallback (a direct terminal takes the divergenceRebuild
|
|
2936
|
+
// full paint above): either the frame shrank into the committed
|
|
2937
|
+
// prefix, or a committed-prefix resync left a focused cursor tail
|
|
2938
|
+
// shorter than the viewport. The latter happens when a streaming/live
|
|
2939
|
+
// block had an append-only prefix committed, then collapses on
|
|
2940
|
+
// abort/finalize: the audit re-anchors #committedRows at the first
|
|
2941
|
+
// divergent row, but flooring windowTop there would pin the editor
|
|
2942
|
+
// near the top and leave blank rows underneath. Re-show the frame
|
|
2943
|
+
// tail instead. The stale committed copy stays in native history;
|
|
2944
|
+
// duplicating a few rows is preferable to a live editor gap —
|
|
2945
|
+
// "duplication, never loss" is the ED3-unsafe fallback contract.
|
|
2877
2946
|
committedPrefixResliced = true;
|
|
2878
2947
|
windowTop = Math.max(0, frameLength - height);
|
|
2879
2948
|
chunkTo = windowTop;
|
|
@@ -2926,7 +2995,10 @@ export class TUI extends Container {
|
|
|
2926
2995
|
const cursorTrackingLineCount = hasVisibleOverlay ? Math.max(frame.length, windowTop + height) : frame.length;
|
|
2927
2996
|
|
|
2928
2997
|
const intent: RenderIntent = fullPaint
|
|
2929
|
-
? {
|
|
2998
|
+
? {
|
|
2999
|
+
kind: "fullPaint",
|
|
3000
|
+
clearScrollback: divergenceRebuild || ((replaceRequested || geometryRebuild) && !isMultiplexerSession()),
|
|
3001
|
+
}
|
|
2930
3002
|
: { kind: "update", chunkTo, windowTop };
|
|
2931
3003
|
this.#logRedraw(intent, frameLength, height);
|
|
2932
3004
|
|