@oh-my-pi/pi-tui 15.10.3 → 15.10.5
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/components/editor.d.ts +6 -0
- package/dist/types/terminal.d.ts +25 -9
- package/dist/types/tui.d.ts +1 -1
- package/package.json +3 -3
- package/src/components/editor.ts +90 -28
- package/src/stdin-buffer.ts +47 -37
- package/src/terminal.ts +99 -33
- package/src/tui.ts +108 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.10.5] - 2026-06-08
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `atomicTokenPattern` to `Editor`: when set to a global regex matching placeholder tokens such as `[Image #1, 800x600]` or `[Paste #2, +30 lines]`, a single backspace or forward-delete landing anywhere on a token removes the whole token instead of corrupting it into stray text.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Changed the large-paste placeholder label from `[paste #N +X lines]`/`[paste #N Y chars]` to `[Paste #N, +X lines]`/`[Paste #N, Y chars]`.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Fixed pasting large text lagging the prompt for hundreds of milliseconds before the `[paste #N …]` placeholder appeared. `StdinBuffer` assembled bracketed pastes by re-concatenating and re-scanning the entire accumulated buffer on every incoming stdin chunk (`#pasteBuffer += chunk; indexOf(END)`), which is O(n²) in the paste size and dominates when the terminal/PTY delivers the paste in many small reads (SSH, tmux, slow hosts) — a 1 MB paste at 1 KB chunks cost ~33 ms and 5 MB ~740 ms. Chunks are now collected in an array and joined once when the end marker arrives, with a short overlap tail carried across chunk boundaries so a marker split between two reads is still detected without rescanning, making assembly O(n) (~1 ms for 5 MB). The `Editor` paste cleaner also dropped its `split("").filter().join("")` per-code-unit array allocation in favor of a single control-character regex pass (~20× faster on large pastes).
|
|
18
|
+
|
|
19
|
+
## [15.10.4] - 2026-06-08
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Fixed Windows ConPTY session-resume painting the transcript with the last several rows truncated below the viewport until Alt+Tab forced a host repaint. After `sessionReplace`/`historyRebuild`/`overlayRebuild` paints that scroll-push content into native scrollback, the renderer now arms a 150 ms ConPTY settle window that coalesces spinner/blink-driven `requestRender(false)` calls into a single trailing render — Windows Terminal's viewport-follow logic no longer falls further behind the cursor on every tick of the post-paint storm. The arm also reclaims any render request queued *during* the in-flight composition (notably `ImageBudget.endPass()` calling `requestRender()` synchronously when a frame trips the live-graphics cap): without that, the queued request sat on the standard 30 Hz throttle and fired at ~33 ms — well inside the 150 ms quiet window — defeating the coalescing. Bumped the ConPTY per-`WriteFile` chunk cap from 8 KiB to 16 KiB so a multi-megabyte resume paint emits half as many writes (still well under the ~32 KiB threshold from #2034 that the original cap defends against), and made the cap measure encoded UTF-8 bytes instead of JS code units so a CJK-heavy transcript can't silently inflate a 16-KiB-of-code-units chunk into ~48 KiB of `WriteFile` traffic and reintroduce the #2034 viewport bug ([#2095](https://github.com/can1357/oh-my-pi/issues/2095)).
|
|
24
|
+
|
|
5
25
|
## [15.10.3] - 2026-06-08
|
|
6
26
|
|
|
7
27
|
### Fixed
|
|
@@ -37,6 +37,12 @@ export declare class Editor implements Component, Focusable {
|
|
|
37
37
|
decorateText: ((text: string) => string) | undefined;
|
|
38
38
|
borderColor: (str: string) => string;
|
|
39
39
|
onAutocompleteUpdate?: () => void;
|
|
40
|
+
/** Optional pattern matching atomic placeholder tokens (e.g. `[Image #1, 800x600]` or
|
|
41
|
+
* `[Paste #2, +30 lines]`) that the editor treats as indivisible: a backspace or forward-delete
|
|
42
|
+
* landing on any character of a token removes the whole token instead of corrupting it into
|
|
43
|
+
* stray text. MUST be a global regex; the editor recompiles a private copy so its `lastIndex`
|
|
44
|
+
* is never shared with the caller. */
|
|
45
|
+
atomicTokenPattern: RegExp | undefined;
|
|
40
46
|
onSubmit?: (text: string) => void;
|
|
41
47
|
onAltEnter?: (text: string) => void;
|
|
42
48
|
onChange?: (text: string) => void;
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Split `data` into chunks
|
|
3
|
-
* boundary (`\n`) as the cut point so
|
|
4
|
-
* `\n`) stay intact. The TUI's
|
|
5
|
-
* (`buffer += "\r\n"` between rows),
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
2
|
+
* Split `data` into chunks whose encoded UTF-8 byte length is no greater than
|
|
3
|
+
* `maxChunkBytes`, preferring a line boundary (`\n`) as the cut point so
|
|
4
|
+
* escape sequences (which never contain `\n`) stay intact. The TUI's
|
|
5
|
+
* full-paint buffers are line-structured (`buffer += "\r\n"` between rows),
|
|
6
|
+
* so a newline almost always exists within the window. The fallback for a
|
|
7
|
+
* buffer with no newline in range is a hard cut at the last UTF-8 code-point
|
|
8
|
+
* boundary that still fits — the ConPTY viewport bug from a single oversized
|
|
9
|
+
* write is strictly worse than a one-frame escape-sequence glitch on a
|
|
10
|
+
* buffer the renderer effectively never produces.
|
|
11
|
+
*
|
|
12
|
+
* UTF-16 code units are walked manually rather than measuring with
|
|
13
|
+
* `Buffer.byteLength` per slice candidate: each code unit's UTF-8 width is
|
|
14
|
+
* known from its value (BMP `<0x80` → 1, `<0x800` → 2, surrogate pair → 4
|
|
15
|
+
* bytes across two units, other BMP → 3), and surrogate pairs are kept
|
|
16
|
+
* together so the chunker never splits a non-BMP character.
|
|
10
17
|
*
|
|
11
18
|
* Exported for unit testing of the chunking contract; `#safeWrite` is the
|
|
12
19
|
* sole production caller.
|
|
13
20
|
*/
|
|
14
|
-
export declare function chunkForConPTY(data: string,
|
|
21
|
+
export declare function chunkForConPTY(data: string, maxChunkBytes?: number): string[];
|
|
15
22
|
/**
|
|
16
23
|
* Emergency terminal restore - call this from signal/crash handlers
|
|
17
24
|
* Resets terminal state without requiring access to the ProcessTerminal instance
|
|
@@ -91,6 +98,15 @@ export interface Terminal {
|
|
|
91
98
|
*/
|
|
92
99
|
onPrivateModeReport?(callback: (mode: number, supported: boolean) => void): void;
|
|
93
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* True when stdout flows through a ConPTY pseudo-console (native win32, or
|
|
103
|
+
* Linux running under WSL where stdout still crosses into ConPTY at the
|
|
104
|
+
* `wslhost` boundary). ConPTY hosts share the per-WriteFile viewport-tracking
|
|
105
|
+
* quirks documented above and on {@link MAX_CONPTY_WRITE_CHUNK_BYTES}, so both
|
|
106
|
+
* `#safeWrite` and the renderer's post-big-paint settle gate hang off this
|
|
107
|
+
* single predicate.
|
|
108
|
+
*/
|
|
109
|
+
export declare function isConPTYHosted(): boolean;
|
|
94
110
|
/**
|
|
95
111
|
* Real terminal using process.stdin/stdout
|
|
96
112
|
*/
|
package/dist/types/tui.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "15.10.
|
|
4
|
+
"version": "15.10.5",
|
|
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": "15.10.
|
|
41
|
-
"@oh-my-pi/pi-utils": "15.10.
|
|
40
|
+
"@oh-my-pi/pi-natives": "15.10.5",
|
|
41
|
+
"@oh-my-pi/pi-utils": "15.10.5",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.4"
|
|
44
44
|
},
|
package/src/components/editor.ts
CHANGED
|
@@ -368,6 +368,15 @@ export class Editor implements Component, Focusable {
|
|
|
368
368
|
#pastes: Map<number, string> = new Map();
|
|
369
369
|
#pasteCounter: number = 0;
|
|
370
370
|
|
|
371
|
+
/** Optional pattern matching atomic placeholder tokens (e.g. `[Image #1, 800x600]` or
|
|
372
|
+
* `[Paste #2, +30 lines]`) that the editor treats as indivisible: a backspace or forward-delete
|
|
373
|
+
* landing on any character of a token removes the whole token instead of corrupting it into
|
|
374
|
+
* stray text. MUST be a global regex; the editor recompiles a private copy so its `lastIndex`
|
|
375
|
+
* is never shared with the caller. */
|
|
376
|
+
atomicTokenPattern: RegExp | undefined;
|
|
377
|
+
#atomicTokenSource: string | undefined;
|
|
378
|
+
#atomicTokenRe: RegExp | undefined;
|
|
379
|
+
|
|
371
380
|
// Bracketed paste mode buffering
|
|
372
381
|
#pasteHandler = new BracketedPasteHandler();
|
|
373
382
|
|
|
@@ -1389,7 +1398,7 @@ export class Editor implements Component, Focusable {
|
|
|
1389
1398
|
#expandPasteMarkers(text: string): string {
|
|
1390
1399
|
let result = text;
|
|
1391
1400
|
for (const [pasteId, pasteContent] of this.#pastes) {
|
|
1392
|
-
const markerRegex = new RegExp(`\\[
|
|
1401
|
+
const markerRegex = new RegExp(`\\[Paste #${pasteId}(?:, (?:\\+\\d+ lines|\\d+ chars))?\\]`, "g");
|
|
1393
1402
|
result = result.replace(markerRegex, () => pasteContent);
|
|
1394
1403
|
}
|
|
1395
1404
|
return result;
|
|
@@ -1627,11 +1636,10 @@ export class Editor implements Component, Focusable {
|
|
|
1627
1636
|
// Convert tabs to spaces (4 spaces per tab)
|
|
1628
1637
|
const tabExpandedText = cleanText.replace(/\t/g, " ");
|
|
1629
1638
|
|
|
1630
|
-
//
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
.join("");
|
|
1639
|
+
// Strip control characters except newline (tabs already expanded above,
|
|
1640
|
+
// CRs already normalized). Single regex pass instead of split/filter/join
|
|
1641
|
+
// to avoid allocating a per-code-unit array for large pastes.
|
|
1642
|
+
let filteredText = tabExpandedText.replace(/[\x00-\x09\x0B-\x1F]/g, "");
|
|
1635
1643
|
|
|
1636
1644
|
// If pasting a file path (starts with /, ~, or .) and the character before
|
|
1637
1645
|
// the cursor is a word character, prepend a space for better readability
|
|
@@ -1654,11 +1662,11 @@ export class Editor implements Component, Focusable {
|
|
|
1654
1662
|
const pasteId = this.#pasteCounter;
|
|
1655
1663
|
this.#pastes.set(pasteId, filteredText);
|
|
1656
1664
|
|
|
1657
|
-
// Insert marker like "[
|
|
1665
|
+
// Insert marker like "[Paste #1, +123 lines]" or "[Paste #1, 1234 chars]"
|
|
1658
1666
|
const marker =
|
|
1659
1667
|
pastedLines.length > 10
|
|
1660
|
-
? `[
|
|
1661
|
-
: `[
|
|
1668
|
+
? `[Paste #${pasteId}, +${pastedLines.length} lines]`
|
|
1669
|
+
: `[Paste #${pasteId}, ${totalChars} chars]`;
|
|
1662
1670
|
this.#insertTextAtCursor(marker);
|
|
1663
1671
|
|
|
1664
1672
|
return;
|
|
@@ -1727,26 +1735,72 @@ export class Editor implements Component, Focusable {
|
|
|
1727
1735
|
if (this.onSubmit) this.onSubmit(result);
|
|
1728
1736
|
}
|
|
1729
1737
|
|
|
1738
|
+
/** Resolve the compiled, global copy of `atomicTokenPattern`, rebuilt only when the source changes. */
|
|
1739
|
+
#getAtomicTokenRe(): RegExp | undefined {
|
|
1740
|
+
const pattern = this.atomicTokenPattern;
|
|
1741
|
+
if (pattern === undefined) {
|
|
1742
|
+
this.#atomicTokenSource = undefined;
|
|
1743
|
+
this.#atomicTokenRe = undefined;
|
|
1744
|
+
return undefined;
|
|
1745
|
+
}
|
|
1746
|
+
if (pattern.source !== this.#atomicTokenSource) {
|
|
1747
|
+
this.#atomicTokenSource = pattern.source;
|
|
1748
|
+
this.#atomicTokenRe = new RegExp(
|
|
1749
|
+
pattern.source,
|
|
1750
|
+
pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`,
|
|
1751
|
+
);
|
|
1752
|
+
}
|
|
1753
|
+
return this.#atomicTokenRe;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
/** Find an atomic token on `line` whose span contains column `col` (`start <= col < end`). */
|
|
1757
|
+
#atomicTokenAt(line: string, col: number): { start: number; end: number } | undefined {
|
|
1758
|
+
const re = this.#getAtomicTokenRe();
|
|
1759
|
+
if (re === undefined) return undefined;
|
|
1760
|
+
re.lastIndex = 0;
|
|
1761
|
+
for (;;) {
|
|
1762
|
+
const match = re.exec(line);
|
|
1763
|
+
if (match === null) break;
|
|
1764
|
+
if (match[0].length === 0) {
|
|
1765
|
+
re.lastIndex = match.index + 1;
|
|
1766
|
+
continue;
|
|
1767
|
+
}
|
|
1768
|
+
const start = match.index;
|
|
1769
|
+
const end = start + match[0].length;
|
|
1770
|
+
if (col < start) break;
|
|
1771
|
+
if (col < end) return { start, end };
|
|
1772
|
+
}
|
|
1773
|
+
return undefined;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1730
1776
|
#handleBackspace(): void {
|
|
1731
1777
|
this.#historyIndex = -1; // Exit history browsing mode
|
|
1732
1778
|
this.#resetKillSequence();
|
|
1733
1779
|
this.#recordUndoState();
|
|
1734
1780
|
|
|
1735
1781
|
if (this.#state.cursorCol > 0) {
|
|
1736
|
-
// Delete grapheme before cursor (handles emojis, combining characters, etc.)
|
|
1737
1782
|
const line = this.#state.lines[this.#state.cursorLine] || "";
|
|
1738
|
-
|
|
1783
|
+
// An atomic placeholder token (image/paste marker) deletes as a unit, so a single
|
|
1784
|
+
// backspace never leaves a half-eaten `[Paste #1, +30 lines` behind as stray text.
|
|
1785
|
+
const token = this.#atomicTokenAt(line, this.#state.cursorCol - 1);
|
|
1786
|
+
if (token !== undefined) {
|
|
1787
|
+
this.#state.lines[this.#state.cursorLine] = line.slice(0, token.start) + line.slice(token.end);
|
|
1788
|
+
this.#setCursorCol(token.start);
|
|
1789
|
+
} else {
|
|
1790
|
+
// Delete grapheme before cursor (handles emojis, combining characters, etc.)
|
|
1791
|
+
const beforeCursor = line.slice(0, this.#state.cursorCol);
|
|
1739
1792
|
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1793
|
+
// Find the last grapheme in the text before cursor
|
|
1794
|
+
const graphemes = [...segmenter.segment(beforeCursor)];
|
|
1795
|
+
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
1796
|
+
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
|
|
1744
1797
|
|
|
1745
|
-
|
|
1746
|
-
|
|
1798
|
+
const before = line.slice(0, this.#state.cursorCol - graphemeLength);
|
|
1799
|
+
const after = line.slice(this.#state.cursorCol);
|
|
1747
1800
|
|
|
1748
|
-
|
|
1749
|
-
|
|
1801
|
+
this.#state.lines[this.#state.cursorLine] = before + after;
|
|
1802
|
+
this.#setCursorCol(this.#state.cursorCol - graphemeLength);
|
|
1803
|
+
}
|
|
1750
1804
|
} else if (this.#state.cursorLine > 0) {
|
|
1751
1805
|
// Merge with previous line
|
|
1752
1806
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
@@ -2218,17 +2272,25 @@ export class Editor implements Component, Focusable {
|
|
|
2218
2272
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2219
2273
|
|
|
2220
2274
|
if (this.#state.cursorCol < currentLine.length) {
|
|
2221
|
-
//
|
|
2222
|
-
const
|
|
2275
|
+
// An atomic placeholder token (image/paste marker) deletes as a unit.
|
|
2276
|
+
const token = this.#atomicTokenAt(currentLine, this.#state.cursorCol);
|
|
2277
|
+
if (token !== undefined) {
|
|
2278
|
+
this.#state.lines[this.#state.cursorLine] =
|
|
2279
|
+
currentLine.slice(0, token.start) + currentLine.slice(token.end);
|
|
2280
|
+
this.#setCursorCol(token.start);
|
|
2281
|
+
} else {
|
|
2282
|
+
// Delete grapheme at cursor position (handles emojis, combining characters, etc.)
|
|
2283
|
+
const afterCursor = currentLine.slice(this.#state.cursorCol);
|
|
2223
2284
|
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2285
|
+
// Find the first grapheme at cursor
|
|
2286
|
+
const graphemes = [...segmenter.segment(afterCursor)];
|
|
2287
|
+
const firstGrapheme = graphemes[0];
|
|
2288
|
+
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
|
|
2228
2289
|
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2290
|
+
const before = currentLine.slice(0, this.#state.cursorCol);
|
|
2291
|
+
const after = currentLine.slice(this.#state.cursorCol + graphemeLength);
|
|
2292
|
+
this.#state.lines[this.#state.cursorLine] = before + after;
|
|
2293
|
+
}
|
|
2232
2294
|
} else if (this.#state.cursorLine < this.#state.lines.length - 1) {
|
|
2233
2295
|
// At end of line - merge with next line
|
|
2234
2296
|
const nextLine = this.#state.lines[this.#state.cursorLine + 1] || "";
|
package/src/stdin-buffer.ts
CHANGED
|
@@ -265,7 +265,8 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
265
265
|
#timeout?: NodeJS.Timeout;
|
|
266
266
|
readonly #timeoutMs: number;
|
|
267
267
|
#pasteMode: boolean = false;
|
|
268
|
-
#
|
|
268
|
+
#pasteChunks: string[] = [];
|
|
269
|
+
#pasteOverlap: string = "";
|
|
269
270
|
#pendingKittyPrintableCodepoint: number | undefined;
|
|
270
271
|
|
|
271
272
|
constructor(options: StdinBufferOptions = {}) {
|
|
@@ -302,24 +303,9 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
302
303
|
this.#buffer += str;
|
|
303
304
|
|
|
304
305
|
if (this.#pasteMode) {
|
|
305
|
-
|
|
306
|
+
const chunk = this.#buffer;
|
|
306
307
|
this.#buffer = "";
|
|
307
|
-
|
|
308
|
-
const endIndex = this.#pasteBuffer.indexOf(BRACKETED_PASTE_END);
|
|
309
|
-
if (endIndex !== -1) {
|
|
310
|
-
const pastedContent = this.#pasteBuffer.slice(0, endIndex);
|
|
311
|
-
const remaining = this.#pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length);
|
|
312
|
-
|
|
313
|
-
this.#pasteMode = false;
|
|
314
|
-
this.#pasteBuffer = "";
|
|
315
|
-
this.#pendingKittyPrintableCodepoint = undefined;
|
|
316
|
-
|
|
317
|
-
this.emit("paste", pastedContent);
|
|
318
|
-
|
|
319
|
-
if (remaining.length > 0) {
|
|
320
|
-
this.process(remaining);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
308
|
+
this.#consumePasteChunk(chunk);
|
|
323
309
|
return;
|
|
324
310
|
}
|
|
325
311
|
|
|
@@ -335,25 +321,12 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
335
321
|
|
|
336
322
|
this.#pendingKittyPrintableCodepoint = undefined;
|
|
337
323
|
this.#buffer = this.#buffer.slice(startIndex + BRACKETED_PASTE_START.length);
|
|
338
|
-
|
|
339
|
-
this.#pasteBuffer = this.#buffer;
|
|
324
|
+
const firstChunk = this.#buffer;
|
|
340
325
|
this.#buffer = "";
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const remaining = this.#pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length);
|
|
346
|
-
|
|
347
|
-
this.#pasteMode = false;
|
|
348
|
-
this.#pasteBuffer = "";
|
|
349
|
-
this.#pendingKittyPrintableCodepoint = undefined;
|
|
350
|
-
|
|
351
|
-
this.emit("paste", pastedContent);
|
|
352
|
-
|
|
353
|
-
if (remaining.length > 0) {
|
|
354
|
-
this.process(remaining);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
326
|
+
this.#pasteMode = true;
|
|
327
|
+
this.#pasteChunks = [];
|
|
328
|
+
this.#pasteOverlap = "";
|
|
329
|
+
this.#consumePasteChunk(firstChunk);
|
|
357
330
|
return;
|
|
358
331
|
}
|
|
359
332
|
|
|
@@ -375,6 +348,42 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
375
348
|
}
|
|
376
349
|
}
|
|
377
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Consume one chunk of paste-mode input. Chunks are accumulated in an array
|
|
353
|
+
* and only joined once the end marker arrives, so a large paste delivered in
|
|
354
|
+
* many small terminal reads stays O(total) instead of the O(total^2) cost of
|
|
355
|
+
* re-concatenating and rescanning the whole buffer on every chunk. A short
|
|
356
|
+
* overlap tail (end-marker length - 1) is carried across chunk boundaries so
|
|
357
|
+
* a marker split between two reads is still detected without rescanning.
|
|
358
|
+
*/
|
|
359
|
+
#consumePasteChunk(chunk: string): void {
|
|
360
|
+
const probe = this.#pasteOverlap + chunk;
|
|
361
|
+
if (probe.indexOf(BRACKETED_PASTE_END) === -1) {
|
|
362
|
+
this.#pasteChunks.push(chunk);
|
|
363
|
+
const keep = BRACKETED_PASTE_END.length - 1;
|
|
364
|
+
this.#pasteOverlap = probe.length > keep ? probe.slice(probe.length - keep) : probe;
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// End marker arrived: join once and split at its first occurrence,
|
|
369
|
+
// matching the prior indexOf-from-start semantics exactly.
|
|
370
|
+
const flat = this.#pasteChunks.length > 0 ? `${this.#pasteChunks.join("")}${chunk}` : chunk;
|
|
371
|
+
const endIndex = flat.indexOf(BRACKETED_PASTE_END);
|
|
372
|
+
const pastedContent = flat.slice(0, endIndex);
|
|
373
|
+
const remaining = flat.slice(endIndex + BRACKETED_PASTE_END.length);
|
|
374
|
+
|
|
375
|
+
this.#pasteMode = false;
|
|
376
|
+
this.#pasteChunks = [];
|
|
377
|
+
this.#pasteOverlap = "";
|
|
378
|
+
this.#pendingKittyPrintableCodepoint = undefined;
|
|
379
|
+
|
|
380
|
+
this.emit("paste", pastedContent);
|
|
381
|
+
|
|
382
|
+
if (remaining.length > 0) {
|
|
383
|
+
this.process(remaining);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
378
387
|
#emitDataSequence(sequence: string): void {
|
|
379
388
|
const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined;
|
|
380
389
|
if (rawCodepoint !== undefined && rawCodepoint === this.#pendingKittyPrintableCodepoint) {
|
|
@@ -409,7 +418,8 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
409
418
|
}
|
|
410
419
|
this.#buffer = "";
|
|
411
420
|
this.#pasteMode = false;
|
|
412
|
-
this.#
|
|
421
|
+
this.#pasteChunks = [];
|
|
422
|
+
this.#pasteOverlap = "";
|
|
413
423
|
this.#pendingKittyPrintableCodepoint = undefined;
|
|
414
424
|
}
|
|
415
425
|
|
package/src/terminal.ts
CHANGED
|
@@ -10,7 +10,7 @@ const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
|
|
|
10
10
|
const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Maximum bytes per `process.stdout.write` call on Windows.
|
|
13
|
+
* Maximum encoded UTF-8 bytes per `process.stdout.write` call on Windows.
|
|
14
14
|
*
|
|
15
15
|
* Windows ConPTY ties viewport tracking to per-`WriteFile` boundaries: when a
|
|
16
16
|
* single write exceeds ~32-64 KB, the pseudo-console stops following the
|
|
@@ -20,43 +20,96 @@ const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07";
|
|
|
20
20
|
* first ~30 lines until any focus event forces the host to re-query the
|
|
21
21
|
* cursor. The data is delivered correctly — it's purely a viewport-sync bug.
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* The cap is on **encoded UTF-8 bytes**, not JS code units, because
|
|
24
|
+
* `process.stdout.write(string)` UTF-8-encodes before handing off to
|
|
25
|
+
* `WriteFile`. A pure-CJK transcript row encodes to ~3 bytes per BMP code
|
|
26
|
+
* unit, so a code-unit-based cap of 16 KiB could land at ~48 KiB of actual
|
|
27
|
+
* `WriteFile` traffic and reintroduce the #2034 parked-viewport bug for
|
|
28
|
+
* non-ASCII content.
|
|
29
|
+
*
|
|
30
|
+
* 16 KiB is half the smallest observed Windows Terminal threshold (32 KiB),
|
|
31
|
+
* which keeps the per-write parked-viewport bug fixed by #2034 while halving
|
|
32
|
+
* the WriteFile count on multi-megabyte paints (a 3 MB session resume splits
|
|
33
|
+
* into ~192 chunks instead of ~384). Fewer WriteFiles means fewer chances for
|
|
34
|
+
* WT's viewport-following logic to lose track of the cursor during the burst,
|
|
35
|
+
* which mitigates the residual mid-paint drift the original 8 KiB cap left
|
|
36
|
+
* behind (#2095). Still well clear of the threshold so the other ConPTY hosts
|
|
37
|
+
* (Tabby, Hyper, VS Code) — where the exact limit is undocumented — keep
|
|
38
|
+
* their safety margin.
|
|
27
39
|
*/
|
|
28
|
-
const
|
|
40
|
+
const MAX_CONPTY_WRITE_CHUNK_BYTES = 16 * 1024;
|
|
29
41
|
|
|
30
42
|
/**
|
|
31
|
-
* Split `data` into chunks
|
|
32
|
-
* boundary (`\n`) as the cut point so
|
|
33
|
-
* `\n`) stay intact. The TUI's
|
|
34
|
-
* (`buffer += "\r\n"` between rows),
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
43
|
+
* Split `data` into chunks whose encoded UTF-8 byte length is no greater than
|
|
44
|
+
* `maxChunkBytes`, preferring a line boundary (`\n`) as the cut point so
|
|
45
|
+
* escape sequences (which never contain `\n`) stay intact. The TUI's
|
|
46
|
+
* full-paint buffers are line-structured (`buffer += "\r\n"` between rows),
|
|
47
|
+
* so a newline almost always exists within the window. The fallback for a
|
|
48
|
+
* buffer with no newline in range is a hard cut at the last UTF-8 code-point
|
|
49
|
+
* boundary that still fits — the ConPTY viewport bug from a single oversized
|
|
50
|
+
* write is strictly worse than a one-frame escape-sequence glitch on a
|
|
51
|
+
* buffer the renderer effectively never produces.
|
|
52
|
+
*
|
|
53
|
+
* UTF-16 code units are walked manually rather than measuring with
|
|
54
|
+
* `Buffer.byteLength` per slice candidate: each code unit's UTF-8 width is
|
|
55
|
+
* known from its value (BMP `<0x80` → 1, `<0x800` → 2, surrogate pair → 4
|
|
56
|
+
* bytes across two units, other BMP → 3), and surrogate pairs are kept
|
|
57
|
+
* together so the chunker never splits a non-BMP character.
|
|
39
58
|
*
|
|
40
59
|
* Exported for unit testing of the chunking contract; `#safeWrite` is the
|
|
41
60
|
* sole production caller.
|
|
42
61
|
*/
|
|
43
|
-
export function chunkForConPTY(data: string,
|
|
44
|
-
|
|
62
|
+
export function chunkForConPTY(data: string, maxChunkBytes: number = MAX_CONPTY_WRITE_CHUNK_BYTES): string[] {
|
|
63
|
+
// Fast path: whole buffer fits in one write.
|
|
64
|
+
if (Buffer.byteLength(data, "utf8") <= maxChunkBytes) return [data];
|
|
45
65
|
const chunks: string[] = [];
|
|
66
|
+
const len = data.length;
|
|
46
67
|
let pos = 0;
|
|
47
|
-
while (pos <
|
|
48
|
-
|
|
49
|
-
|
|
68
|
+
while (pos < len) {
|
|
69
|
+
let bytes = 0;
|
|
70
|
+
// Index just past the most recent `\n` we've consumed inside [pos, i):
|
|
71
|
+
// the natural cut point that leaves escape sequences intact.
|
|
72
|
+
let lastNewlineEnd = -1;
|
|
73
|
+
let i = pos;
|
|
74
|
+
while (i < len) {
|
|
75
|
+
const cu = data.charCodeAt(i);
|
|
76
|
+
let cuLen = 1;
|
|
77
|
+
let cuBytes: number;
|
|
78
|
+
if (cu < 0x80) {
|
|
79
|
+
cuBytes = 1;
|
|
80
|
+
} else if (cu < 0x800) {
|
|
81
|
+
cuBytes = 2;
|
|
82
|
+
} else if (cu >= 0xd800 && cu < 0xdc00) {
|
|
83
|
+
// High surrogate: pair with the following low surrogate (4 bytes
|
|
84
|
+
// across two code units); an unpaired surrogate UTF-8-encodes as
|
|
85
|
+
// the 3-byte U+FFFD replacement character.
|
|
86
|
+
const next = i + 1 < len ? data.charCodeAt(i + 1) : 0;
|
|
87
|
+
if (next >= 0xdc00 && next < 0xe000) {
|
|
88
|
+
cuBytes = 4;
|
|
89
|
+
cuLen = 2;
|
|
90
|
+
} else {
|
|
91
|
+
cuBytes = 3;
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
// BMP non-surrogate or unpaired low surrogate → 3 bytes.
|
|
95
|
+
cuBytes = 3;
|
|
96
|
+
}
|
|
97
|
+
if (bytes + cuBytes > maxChunkBytes && i > pos) {
|
|
98
|
+
// Would overflow the cap. Cut at the last newline if we found one,
|
|
99
|
+
// otherwise hard-cut at the current code-point boundary.
|
|
100
|
+
const cut = lastNewlineEnd > pos ? lastNewlineEnd : i;
|
|
101
|
+
chunks.push(data.slice(pos, cut));
|
|
102
|
+
pos = cut;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
bytes += cuBytes;
|
|
106
|
+
i += cuLen;
|
|
107
|
+
if (cu === 0x0a) lastNewlineEnd = i;
|
|
108
|
+
}
|
|
109
|
+
if (i >= len) {
|
|
50
110
|
chunks.push(data.slice(pos));
|
|
51
|
-
|
|
111
|
+
pos = len;
|
|
52
112
|
}
|
|
53
|
-
const windowEnd = pos + maxChunkSize;
|
|
54
|
-
// Prefer the last newline inside the window so escape sequences stay
|
|
55
|
-
// intact within their chunk; hard-cut at `windowEnd` otherwise.
|
|
56
|
-
const nl = data.lastIndexOf("\n", windowEnd - 1);
|
|
57
|
-
const cut = nl >= pos ? nl + 1 : windowEnd;
|
|
58
|
-
chunks.push(data.slice(pos, cut));
|
|
59
|
-
pos = cut;
|
|
60
113
|
}
|
|
61
114
|
return chunks;
|
|
62
115
|
}
|
|
@@ -202,7 +255,17 @@ export interface Terminal {
|
|
|
202
255
|
onPrivateModeReport?(callback: (mode: number, supported: boolean) => void): void;
|
|
203
256
|
}
|
|
204
257
|
|
|
205
|
-
|
|
258
|
+
/**
|
|
259
|
+
* True when stdout flows through a ConPTY pseudo-console (native win32, or
|
|
260
|
+
* Linux running under WSL where stdout still crosses into ConPTY at the
|
|
261
|
+
* `wslhost` boundary). ConPTY hosts share the per-WriteFile viewport-tracking
|
|
262
|
+
* quirks documented above and on {@link MAX_CONPTY_WRITE_CHUNK_BYTES}, so both
|
|
263
|
+
* `#safeWrite` and the renderer's post-big-paint settle gate hang off this
|
|
264
|
+
* single predicate.
|
|
265
|
+
*/
|
|
266
|
+
export function isConPTYHosted(): boolean {
|
|
267
|
+
if (process.platform === "win32") return true;
|
|
268
|
+
// WSL: stdout still crosses into ConPTY at the `wslhost` boundary.
|
|
206
269
|
return process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
|
|
207
270
|
}
|
|
208
271
|
|
|
@@ -349,7 +412,8 @@ export class ProcessTerminal implements Terminal {
|
|
|
349
412
|
// Windows Terminal under WSL has been observed to close the hosting tab
|
|
350
413
|
// after repeated OSC 11/DA1 probes. Keep the initial/event-driven probes,
|
|
351
414
|
// but avoid background polling there.
|
|
352
|
-
|
|
415
|
+
const isWSL = process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
|
|
416
|
+
if (!isWSL) {
|
|
353
417
|
this.#startOsc11Poll();
|
|
354
418
|
}
|
|
355
419
|
|
|
@@ -1090,10 +1154,12 @@ export class ProcessTerminal implements Terminal {
|
|
|
1090
1154
|
// WSL — `process.platform === "linux"` there, but stdout still
|
|
1091
1155
|
// crosses into ConPTY at the `wslhost` boundary, so the same per-
|
|
1092
1156
|
// WriteFile cap applies. Non-ConPTY PTYs keep the single-write fast
|
|
1093
|
-
// path.
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1157
|
+
// path. The cap is on encoded UTF-8 bytes, not JS code units, because
|
|
1158
|
+
// `process.stdout.write(string)` UTF-8-encodes before `WriteFile`,
|
|
1159
|
+
// and a code-unit cap would let CJK transcript rows expand past the
|
|
1160
|
+
// threshold. See #2034 and #2095.
|
|
1161
|
+
if (isConPTYHosted() && Buffer.byteLength(data, "utf8") > MAX_CONPTY_WRITE_CHUNK_BYTES) {
|
|
1162
|
+
for (const chunk of chunkForConPTY(data, MAX_CONPTY_WRITE_CHUNK_BYTES)) {
|
|
1097
1163
|
process.stdout.write(chunk);
|
|
1098
1164
|
}
|
|
1099
1165
|
} else {
|
package/src/tui.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { $flag, getDebugLogPath } from "@oh-my-pi/pi-utils";
|
|
|
17
17
|
import { DEFAULT_MAX_INLINE_IMAGES, ImageBudget } from "./components/image";
|
|
18
18
|
import { planDeccaraFills } from "./deccara";
|
|
19
19
|
import { isKeyRelease, matchesKey } from "./keys";
|
|
20
|
-
import type
|
|
20
|
+
import { isConPTYHosted, type Terminal } from "./terminal";
|
|
21
21
|
import {
|
|
22
22
|
encodeKittyDeleteImage,
|
|
23
23
|
ImageProtocol,
|
|
@@ -494,6 +494,26 @@ export class TUI extends Container {
|
|
|
494
494
|
// arrives (issue #2088). Coalescing every SIGWINCH inside this window into
|
|
495
495
|
// a single forced render lets the multiplexer settle first.
|
|
496
496
|
static readonly #MULTIPLEXER_RESIZE_DEBOUNCE_MS = 50;
|
|
497
|
+
// Post-paint settle window for ConPTY hosts. The `sessionReplace` /
|
|
498
|
+
// `historyRebuild` / `overlayRebuild` intents drive `#emitFullPaint` over
|
|
499
|
+
// a transcript that overflows the viewport, scroll-pushing everything past
|
|
500
|
+
// the last `height` rows into native scrollback. Windows Terminal's
|
|
501
|
+
// viewport-follow logic gets lossy during that burst: spinner/blink-driven
|
|
502
|
+
// `requestRender(false)` calls firing inside the window each produce another
|
|
503
|
+
// diff write, and the WT host processes them faster than its viewport
|
|
504
|
+
// tracker can keep up — the visible tail ends up parked a few rows above
|
|
505
|
+
// the actual last row until any focus event (Alt+Tab) forces a host repaint.
|
|
506
|
+
// Coalescing every non-forced render inside this window into a single
|
|
507
|
+
// trailing render lets the host fully settle the big paint before any
|
|
508
|
+
// follow-up writes touch the buffer. The first-ever `initial` paint is
|
|
509
|
+
// deliberately exempt: nothing has been on screen yet, so no drift can
|
|
510
|
+
// have accumulated, and tests that start the TUI over an over-tall
|
|
511
|
+
// component depend on the next paint firing without delay. Only armed on
|
|
512
|
+
// ConPTY hosts (`isConPTYHosted()`); other terminals do not exhibit the
|
|
513
|
+
// drift and would just see an unnecessary post-paint latency. See #2095.
|
|
514
|
+
static readonly #CONPTY_POST_FULL_PAINT_SETTLE_MS = 150;
|
|
515
|
+
#postFullPaintSettleUntilMs = 0;
|
|
516
|
+
#postFullPaintSettleTimer: RenderTimer | undefined;
|
|
497
517
|
#cursorRow = 0; // Logical cursor row (end of rendered content)
|
|
498
518
|
#hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
|
499
519
|
#hardwareCursorState: HardwareCursorState | null = null;
|
|
@@ -1106,6 +1126,7 @@ export class TUI extends Container {
|
|
|
1106
1126
|
this.#multiplexerResizeTimer.cancel();
|
|
1107
1127
|
this.#multiplexerResizeTimer = undefined;
|
|
1108
1128
|
}
|
|
1129
|
+
this.#clearPostFullPaintSettle();
|
|
1109
1130
|
this.#deferredForcedClearScrollback = false;
|
|
1110
1131
|
// Place the parent shell on the first line after the rendered content. When
|
|
1111
1132
|
// that line is still inside the viewport, moving there and writing `\r` is
|
|
@@ -1214,6 +1235,10 @@ export class TUI extends Container {
|
|
|
1214
1235
|
this.#armMultiplexerResizeTimer(options?.clearScrollback === true);
|
|
1215
1236
|
return;
|
|
1216
1237
|
}
|
|
1238
|
+
// A forced render preempts the post-full-paint ConPTY settle: it owns
|
|
1239
|
+
// the next paint and is going to redraw the buffer anyway, so the
|
|
1240
|
+
// trailing coalesced render queued by the settle would only race it.
|
|
1241
|
+
this.#clearPostFullPaintSettle();
|
|
1217
1242
|
this.#prepareForcedRender(options?.clearScrollback === true);
|
|
1218
1243
|
this.#renderRequested = true;
|
|
1219
1244
|
this.#renderScheduler.scheduleImmediate(() => {
|
|
@@ -1226,6 +1251,27 @@ export class TUI extends Container {
|
|
|
1226
1251
|
});
|
|
1227
1252
|
return;
|
|
1228
1253
|
}
|
|
1254
|
+
// Coalesce non-forced renders inside the post-full-paint ConPTY settle
|
|
1255
|
+
// window into one trailing render. Spinner/blink/streaming components
|
|
1256
|
+
// otherwise fire `requestRender(false)` at 30 Hz while the host is still
|
|
1257
|
+
// catching up with the previous big paint, and each follow-up viewport
|
|
1258
|
+
// repaint nudges Windows Terminal's viewport tracker further off the
|
|
1259
|
+
// last row (see #2095).
|
|
1260
|
+
if (this.#postFullPaintSettleUntilMs > 0) {
|
|
1261
|
+
const now = this.#renderScheduler.now();
|
|
1262
|
+
if (now < this.#postFullPaintSettleUntilMs) {
|
|
1263
|
+
if (this.#postFullPaintSettleTimer === undefined) {
|
|
1264
|
+
this.#postFullPaintSettleTimer = this.#renderScheduler.scheduleRender(() => {
|
|
1265
|
+
this.#postFullPaintSettleTimer = undefined;
|
|
1266
|
+
this.#postFullPaintSettleUntilMs = 0;
|
|
1267
|
+
if (this.#stopped) return;
|
|
1268
|
+
this.requestRender(false);
|
|
1269
|
+
}, this.#postFullPaintSettleUntilMs - now);
|
|
1270
|
+
}
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
this.#postFullPaintSettleUntilMs = 0;
|
|
1274
|
+
}
|
|
1229
1275
|
if (this.#renderRequested) return;
|
|
1230
1276
|
this.#renderRequested = true;
|
|
1231
1277
|
this.#renderScheduler.scheduleImmediate(() => this.#scheduleRender());
|
|
@@ -1262,6 +1308,64 @@ export class TUI extends Container {
|
|
|
1262
1308
|
this.requestRender(true, { clearScrollback: deferredClearScrollback });
|
|
1263
1309
|
}, TUI.#MULTIPLEXER_RESIZE_DEBOUNCE_MS);
|
|
1264
1310
|
}
|
|
1311
|
+
|
|
1312
|
+
/**
|
|
1313
|
+
* Arm the post-full-paint settle window after an `#emitFullPaint` that
|
|
1314
|
+
* pushed content into native scrollback on a ConPTY host. Idempotent inside
|
|
1315
|
+
* the window: a later overflowing paint extends `until` to the later
|
|
1316
|
+
* deadline so back-to-back big paints do not double-fire the trailing
|
|
1317
|
+
* coalesced render, and the existing deferred timer is rescheduled to the
|
|
1318
|
+
* later deadline.
|
|
1319
|
+
*
|
|
1320
|
+
* Mid-composition callers (most notably `ImageBudget.endPass()`, which can
|
|
1321
|
+
* call `requestRender()` from inside the in-flight paint when a new image
|
|
1322
|
+
* trips the budget) queue their render *before* the settle exists, so they
|
|
1323
|
+
* fall through the gate and set `#renderRequested` / `#renderTimer` on the
|
|
1324
|
+
* 30 Hz throttle. Without absorbing those, the throttled follow-up fires
|
|
1325
|
+
* inside the 150 ms quiet window and reintroduces the cascade the settle
|
|
1326
|
+
* was meant to stop. Cancel both, then eagerly arm the trailing settle
|
|
1327
|
+
* timer so the in-flight request still rides one coalesced render at the
|
|
1328
|
+
* end of the window. See #2095.
|
|
1329
|
+
*/
|
|
1330
|
+
#armPostFullPaintSettle(): void {
|
|
1331
|
+
if (!isConPTYHosted()) return;
|
|
1332
|
+
const until = this.#renderScheduler.now() + TUI.#CONPTY_POST_FULL_PAINT_SETTLE_MS;
|
|
1333
|
+
if (until <= this.#postFullPaintSettleUntilMs) return;
|
|
1334
|
+
this.#postFullPaintSettleUntilMs = until;
|
|
1335
|
+
const hadPendingRender = this.#renderRequested || this.#renderTimer !== undefined;
|
|
1336
|
+
// Reclaim any render that was queued during the in-flight composition:
|
|
1337
|
+
// `#renderRequested` was set before the settle existed and would
|
|
1338
|
+
// otherwise fire on the standard throttle inside the window.
|
|
1339
|
+
this.#renderRequested = false;
|
|
1340
|
+
if (this.#renderTimer) {
|
|
1341
|
+
this.#renderTimer.cancel();
|
|
1342
|
+
this.#renderTimer = undefined;
|
|
1343
|
+
}
|
|
1344
|
+
if (this.#postFullPaintSettleTimer) {
|
|
1345
|
+
this.#postFullPaintSettleTimer.cancel();
|
|
1346
|
+
this.#postFullPaintSettleTimer = undefined;
|
|
1347
|
+
}
|
|
1348
|
+
if (hadPendingRender) {
|
|
1349
|
+
// Replay the absorbed request via the trailing settle timer so the
|
|
1350
|
+
// caller's render still happens — just deferred to the end of the
|
|
1351
|
+
// window. Subsequent `requestRender(false)` calls during the
|
|
1352
|
+
// settle see this timer and fold into it (existing gate at L1263).
|
|
1353
|
+
this.#postFullPaintSettleTimer = this.#renderScheduler.scheduleRender(() => {
|
|
1354
|
+
this.#postFullPaintSettleTimer = undefined;
|
|
1355
|
+
this.#postFullPaintSettleUntilMs = 0;
|
|
1356
|
+
if (this.#stopped) return;
|
|
1357
|
+
this.requestRender(false);
|
|
1358
|
+
}, TUI.#CONPTY_POST_FULL_PAINT_SETTLE_MS);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
#clearPostFullPaintSettle(): void {
|
|
1363
|
+
if (this.#postFullPaintSettleTimer) {
|
|
1364
|
+
this.#postFullPaintSettleTimer.cancel();
|
|
1365
|
+
this.#postFullPaintSettleTimer = undefined;
|
|
1366
|
+
}
|
|
1367
|
+
this.#postFullPaintSettleUntilMs = 0;
|
|
1368
|
+
}
|
|
1265
1369
|
#prepareForcedRender(clearScrollback: boolean): void {
|
|
1266
1370
|
const geometryChanged =
|
|
1267
1371
|
(this.#previousWidth > 0 && this.#previousWidth !== this.terminal.columns) ||
|
|
@@ -1927,6 +2031,7 @@ export class TUI extends Container {
|
|
|
1927
2031
|
clearViewport: true,
|
|
1928
2032
|
clearScrollback: !isMultiplexerSession(),
|
|
1929
2033
|
});
|
|
2034
|
+
if (lines.length > height) this.#armPostFullPaintSettle();
|
|
1930
2035
|
this.#hasEverRendered = true;
|
|
1931
2036
|
return;
|
|
1932
2037
|
case "historyRebuild":
|
|
@@ -1935,6 +2040,7 @@ export class TUI extends Container {
|
|
|
1935
2040
|
clearViewport: true,
|
|
1936
2041
|
clearScrollback: !isMultiplexerSession(),
|
|
1937
2042
|
});
|
|
2043
|
+
if (lines.length > height) this.#armPostFullPaintSettle();
|
|
1938
2044
|
return;
|
|
1939
2045
|
case "overlayRebuild":
|
|
1940
2046
|
this.#clearNativeScrollbackDirty();
|
|
@@ -1945,6 +2051,7 @@ export class TUI extends Container {
|
|
|
1945
2051
|
clearScrollback: !isMultiplexerSession(),
|
|
1946
2052
|
});
|
|
1947
2053
|
this.#emitViewportRepaint(lines, width, height, cursorPos);
|
|
2054
|
+
if (baseLines.length > height) this.#armPostFullPaintSettle();
|
|
1948
2055
|
return;
|
|
1949
2056
|
case "liveRegionPinned":
|
|
1950
2057
|
this.#emitLiveRegionPinnedRepaint(
|