@oh-my-pi/pi-tui 16.2.12 → 16.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -1
- package/dist/types/bracketed-paste.d.ts +19 -1
- package/dist/types/components/editor.d.ts +16 -0
- package/dist/types/fuzzy.d.ts +8 -0
- package/dist/types/terminal-capabilities.d.ts +2 -0
- package/package.json +3 -3
- package/src/bracketed-paste.ts +46 -7
- package/src/components/editor.ts +30 -3
- package/src/fuzzy.ts +68 -10
- package/src/stdin-buffer.ts +235 -217
- package/src/terminal-capabilities.ts +14 -4
- package/src/terminal.ts +21 -1
- package/src/tui.ts +230 -54
- package/src/utils.ts +2 -1
package/src/tui.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { isConPTYHosted, setAltScreenActive, type Terminal } from "./terminal";
|
|
|
21
21
|
import {
|
|
22
22
|
encodeKittyDeleteImage,
|
|
23
23
|
ImageProtocol,
|
|
24
|
+
isInsideTerminalMultiplexer,
|
|
24
25
|
setCellDimensions,
|
|
25
26
|
setTerminalImageProtocol,
|
|
26
27
|
shouldEnableSynchronizedOutputByDefault,
|
|
@@ -387,17 +388,7 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu
|
|
|
387
388
|
|
|
388
389
|
/** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
|
|
389
390
|
function isMultiplexerSession(): boolean {
|
|
390
|
-
|
|
391
|
-
// signals. TERM can also survive when those are stripped (`sudo` without -E,
|
|
392
|
-
// `su`, env-sanitizing launchers/ssh), so keep the TERM prefix fallback aligned
|
|
393
|
-
// with sibling multiplexer checks (terminal-capabilities.ts). Misclassifying a
|
|
394
|
-
// multiplexer as a direct terminal lets resize/reset paths emit ED3 (`CSI 3 J`)
|
|
395
|
-
// and wipe pane scrollback. Do not use CMUX_SOCKET_PATH here: it is a CLI socket
|
|
396
|
-
// override and can be set outside a CMUX terminal.
|
|
397
|
-
if (Bun.env.TMUX || Bun.env.STY || Bun.env.ZELLIJ) return true;
|
|
398
|
-
if (Bun.env.CMUX_WORKSPACE_ID || Bun.env.CMUX_SURFACE_ID) return true;
|
|
399
|
-
const term = Bun.env.TERM?.toLowerCase() ?? "";
|
|
400
|
-
return term.startsWith("tmux") || term.startsWith("screen");
|
|
391
|
+
return isInsideTerminalMultiplexer();
|
|
401
392
|
}
|
|
402
393
|
|
|
403
394
|
/**
|
|
@@ -923,8 +914,23 @@ export class TUI extends Container {
|
|
|
923
914
|
#renderTimer: RenderTimer | undefined;
|
|
924
915
|
#renderScheduler: RenderScheduler;
|
|
925
916
|
#lastRenderAt = 0;
|
|
917
|
+
/**
|
|
918
|
+
* Wall-clock cost of the most recent `#doRender()` call. Used by
|
|
919
|
+
* `#scheduleRender` to inflate the next render delay proportionally so a
|
|
920
|
+
* spike of slow frames (large transcript diffs, huge assistant text wrap,
|
|
921
|
+
* component-tree walks) does not busy-loop the CPU: the throttle would
|
|
922
|
+
* otherwise collapse to zero once `elapsed >= MIN_RENDER_INTERVAL_MS` and
|
|
923
|
+
* fire the next frame immediately (see #4145).
|
|
924
|
+
*/
|
|
925
|
+
#lastFrameCostMs = 0;
|
|
926
926
|
static readonly #MIN_RENDER_INTERVAL_MS = 1000 / 30;
|
|
927
927
|
static readonly #INPUT_RENDER_GRACE_MS = TUI.#MIN_RENDER_INTERVAL_MS;
|
|
928
|
+
/**
|
|
929
|
+
* Cap on the adaptive floor derived from `#lastFrameCostMs`. Bounds the UI
|
|
930
|
+
* responsiveness at ~5 fps under sustained heavy renders — anything slower
|
|
931
|
+
* feels dead to the user and no longer justifies further CPU savings.
|
|
932
|
+
*/
|
|
933
|
+
static readonly #MAX_ADAPTIVE_RENDER_MS = 200;
|
|
928
934
|
#inputRenderGraceUntilMs = 0;
|
|
929
935
|
// Pane-reflow settle window for tmux/screen/zellij. The host process gets
|
|
930
936
|
// SIGWINCH (and `process.stdout` already reports the new geometry) before
|
|
@@ -972,6 +978,8 @@ export class TUI extends Container {
|
|
|
972
978
|
// ConPTY hosts (`isConPTYHosted()`); other terminals do not exhibit the
|
|
973
979
|
// drift and would just see an unnecessary post-paint latency. See #2095.
|
|
974
980
|
static readonly #CONPTY_POST_FULL_PAINT_SETTLE_MS = 150;
|
|
981
|
+
static readonly #CONPTY_FRAME_TRUNCATE_THRESHOLD_BYTES = 512 * 1024;
|
|
982
|
+
static readonly #CONPTY_FRAME_RETAIN_BYTES = 64 * 1024;
|
|
975
983
|
#postFullPaintSettleUntilMs = 0;
|
|
976
984
|
#postFullPaintSettleTimer: RenderTimer | undefined;
|
|
977
985
|
#hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
|
@@ -1831,8 +1839,7 @@ export class TUI extends Container {
|
|
|
1831
1839
|
this.#prepareForcedRender(!isMultiplexerSession());
|
|
1832
1840
|
this.#resizeEventPending = true;
|
|
1833
1841
|
this.#renderRequested = false;
|
|
1834
|
-
this.#
|
|
1835
|
-
this.#doRender();
|
|
1842
|
+
this.#executeRender();
|
|
1836
1843
|
}
|
|
1837
1844
|
|
|
1838
1845
|
requestRender(force = false, options?: RenderRequestOptions): void {
|
|
@@ -1863,8 +1870,7 @@ export class TUI extends Container {
|
|
|
1863
1870
|
return;
|
|
1864
1871
|
}
|
|
1865
1872
|
this.#renderRequested = false;
|
|
1866
|
-
this.#
|
|
1867
|
-
this.#doRender();
|
|
1873
|
+
this.#executeRender();
|
|
1868
1874
|
});
|
|
1869
1875
|
return;
|
|
1870
1876
|
}
|
|
@@ -2085,8 +2091,7 @@ export class TUI extends Container {
|
|
|
2085
2091
|
this.#ghosttyInitialImageDelayTimer = undefined;
|
|
2086
2092
|
this.#ghosttyInitialImageDelayDone = true;
|
|
2087
2093
|
if (this.#stopped) return;
|
|
2088
|
-
this.#
|
|
2089
|
-
this.#doRender();
|
|
2094
|
+
this.#executeRender();
|
|
2090
2095
|
if (this.#renderRequested) this.#scheduleRender();
|
|
2091
2096
|
}, delayMs);
|
|
2092
2097
|
return true;
|
|
@@ -2114,22 +2119,41 @@ export class TUI extends Container {
|
|
|
2114
2119
|
const now = this.#renderScheduler.now();
|
|
2115
2120
|
const elapsed = now - this.#lastRenderAt;
|
|
2116
2121
|
const cadenceDelay = Math.max(0, TUI.#MIN_RENDER_INTERVAL_MS - elapsed);
|
|
2122
|
+
// Adaptive backpressure — target ~50% render duty cycle: the next frame
|
|
2123
|
+
// starts no sooner than `last_frame_end + last_frame_cost`, i.e.
|
|
2124
|
+
// `last_frame_start + 2 × last_frame_cost`. So `elapsed` (which counts
|
|
2125
|
+
// from the last frame's start) must already exceed twice the cost
|
|
2126
|
+
// before we allow the follow-up render to fire. Capped so a
|
|
2127
|
+
// pathological one-off spike doesn't lock the UI (#4145).
|
|
2128
|
+
const adaptiveFloor = Math.min(TUI.#MAX_ADAPTIVE_RENDER_MS, this.#lastFrameCostMs * 2);
|
|
2129
|
+
const adaptiveDelay = Math.max(0, adaptiveFloor - elapsed);
|
|
2117
2130
|
const inputGraceDelay = Math.max(0, this.#inputRenderGraceUntilMs - now);
|
|
2118
|
-
const delay = Math.max(cadenceDelay, inputGraceDelay);
|
|
2131
|
+
const delay = Math.max(cadenceDelay, adaptiveDelay, inputGraceDelay);
|
|
2119
2132
|
this.#renderTimer = this.#renderScheduler.scheduleRender(() => {
|
|
2120
2133
|
this.#renderTimer = undefined;
|
|
2121
2134
|
if (this.#stopped || !this.#renderRequested) {
|
|
2122
2135
|
return;
|
|
2123
2136
|
}
|
|
2124
2137
|
this.#renderRequested = false;
|
|
2125
|
-
this.#
|
|
2126
|
-
this.#doRender();
|
|
2138
|
+
this.#executeRender();
|
|
2127
2139
|
if (this.#renderRequested) {
|
|
2128
2140
|
this.#scheduleRender();
|
|
2129
2141
|
}
|
|
2130
2142
|
}, delay);
|
|
2131
2143
|
}
|
|
2132
2144
|
|
|
2145
|
+
/**
|
|
2146
|
+
* Wrap `#doRender()` so every path records the wall-clock frame cost that
|
|
2147
|
+
* feeds adaptive backpressure. Set `#lastRenderAt` first (some render code
|
|
2148
|
+
* reads it re-entrantly) and compute the cost once the paint returns.
|
|
2149
|
+
*/
|
|
2150
|
+
#executeRender(): void {
|
|
2151
|
+
const start = this.#renderScheduler.now();
|
|
2152
|
+
this.#lastRenderAt = start;
|
|
2153
|
+
this.#doRender();
|
|
2154
|
+
this.#lastFrameCostMs = this.#renderScheduler.now() - start;
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2133
2157
|
#handleInput(data: string): void {
|
|
2134
2158
|
// Raw-mode Ctrl+C/Esc arrive as stdin data, not process signals. If the
|
|
2135
2159
|
// first key in a double-key gesture schedules an immediate slow repaint,
|
|
@@ -2458,6 +2482,56 @@ export class TUI extends Container {
|
|
|
2458
2482
|
return markers;
|
|
2459
2483
|
}
|
|
2460
2484
|
|
|
2485
|
+
#truncateLargeConptyFrame(
|
|
2486
|
+
lines: string[],
|
|
2487
|
+
width: number,
|
|
2488
|
+
height: number,
|
|
2489
|
+
cursorPos: { row: number; col: number } | null,
|
|
2490
|
+
): { lines: string[]; cursorPos: { row: number; col: number } | null } {
|
|
2491
|
+
if (!isConPTYHosted()) return { lines, cursorPos };
|
|
2492
|
+
|
|
2493
|
+
let totalBytes = 0;
|
|
2494
|
+
let exceedsThreshold = false;
|
|
2495
|
+
for (const line of lines) {
|
|
2496
|
+
totalBytes += Buffer.byteLength(line, "utf8") + 8;
|
|
2497
|
+
if (totalBytes > TUI.#CONPTY_FRAME_TRUNCATE_THRESHOLD_BYTES) {
|
|
2498
|
+
exceedsThreshold = true;
|
|
2499
|
+
break;
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
if (!exceedsThreshold) return { lines, cursorPos };
|
|
2503
|
+
|
|
2504
|
+
let retainedBytes = 0;
|
|
2505
|
+
let retainedStart = lines.length;
|
|
2506
|
+
while (
|
|
2507
|
+
retainedStart > 0 &&
|
|
2508
|
+
(retainedBytes < TUI.#CONPTY_FRAME_RETAIN_BYTES || lines.length - retainedStart < height)
|
|
2509
|
+
) {
|
|
2510
|
+
retainedStart -= 1;
|
|
2511
|
+
retainedBytes += Buffer.byteLength(lines[retainedStart] ?? "", "utf8") + 8;
|
|
2512
|
+
}
|
|
2513
|
+
if (retainedStart <= 0) return { lines, cursorPos };
|
|
2514
|
+
|
|
2515
|
+
const marker = truncateToWidth(
|
|
2516
|
+
`[${retainedStart} older lines hidden to keep Windows console resume responsive]`,
|
|
2517
|
+
width,
|
|
2518
|
+
Ellipsis.Omit,
|
|
2519
|
+
);
|
|
2520
|
+
const truncated = new Array<string>(lines.length - retainedStart + 1);
|
|
2521
|
+
truncated[0] = marker;
|
|
2522
|
+
for (let i = retainedStart; i < lines.length; i++) {
|
|
2523
|
+
truncated[i - retainedStart + 1] = lines[i] ?? "";
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
if (cursorPos === null || cursorPos.row < retainedStart) {
|
|
2527
|
+
return { lines: truncated, cursorPos: null };
|
|
2528
|
+
}
|
|
2529
|
+
return {
|
|
2530
|
+
lines: truncated,
|
|
2531
|
+
cursorPos: { row: cursorPos.row - retainedStart + 1, col: cursorPos.col },
|
|
2532
|
+
};
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2461
2535
|
#terminalLine(line: string): string {
|
|
2462
2536
|
if (TERMINAL.isImageLine(line)) return line;
|
|
2463
2537
|
const coalesced = coalesceAdjacentSgr(line);
|
|
@@ -2762,6 +2836,7 @@ export class TUI extends Container {
|
|
|
2762
2836
|
}
|
|
2763
2837
|
window = this.#prepareLinesArray(window, width);
|
|
2764
2838
|
}
|
|
2839
|
+
const cursorTrackingLineCount = hasVisibleOverlay ? Math.max(frame.length, windowTop + height) : frame.length;
|
|
2765
2840
|
|
|
2766
2841
|
const intent: RenderIntent = fullPaint
|
|
2767
2842
|
? { kind: "fullPaint", clearScrollback: replaceRequested || geometryRebuild ? !isMultiplexerSession() : false }
|
|
@@ -2792,6 +2867,7 @@ export class TUI extends Container {
|
|
|
2792
2867
|
clearScrollback: intent.clearScrollback,
|
|
2793
2868
|
chunkTo,
|
|
2794
2869
|
windowTop,
|
|
2870
|
+
cursorTrackingLineCount,
|
|
2795
2871
|
});
|
|
2796
2872
|
this.#committedPrefix = rawFrame.slice(0, chunkTo);
|
|
2797
2873
|
this.#updateCommittedAuditRows(
|
|
@@ -2817,6 +2893,8 @@ export class TUI extends Container {
|
|
|
2817
2893
|
prevWindowTop,
|
|
2818
2894
|
prevHardwareCursorRow,
|
|
2819
2895
|
forceWindowRewrite: this.#forceViewportRepaintOnNextRender || (geometryChanged && resizeRepaintsInPlace()),
|
|
2896
|
+
repaintVirtualScrollInPlace: hasVisibleOverlay,
|
|
2897
|
+
cursorTrackingLineCount,
|
|
2820
2898
|
});
|
|
2821
2899
|
for (let i = this.#committedPrefix.length; i < chunkTo; i++) {
|
|
2822
2900
|
this.#committedPrefix.push(rawFrame[i] ?? "");
|
|
@@ -3198,10 +3276,48 @@ export class TUI extends Container {
|
|
|
3198
3276
|
cursorPos: { row: number; col: number } | null,
|
|
3199
3277
|
purgeSequence: string,
|
|
3200
3278
|
imageTransmitBuffer: string,
|
|
3201
|
-
options: {
|
|
3279
|
+
options: {
|
|
3280
|
+
clearScrollback: boolean;
|
|
3281
|
+
chunkTo: number;
|
|
3282
|
+
windowTop: number;
|
|
3283
|
+
cursorTrackingLineCount: number;
|
|
3284
|
+
},
|
|
3202
3285
|
): void {
|
|
3203
3286
|
this.#fullRedrawCount += 1;
|
|
3204
|
-
const { chunkTo, windowTop } = options;
|
|
3287
|
+
const { chunkTo, windowTop, cursorTrackingLineCount } = options;
|
|
3288
|
+
// Map the frame-space cursor into paint space: committed-prefix rows
|
|
3289
|
+
// keep their index, visible-window rows land after the prefix, and a
|
|
3290
|
+
// cursor in neither region (hidden behind the overlay gap) hides.
|
|
3291
|
+
let paintCursorPos: { row: number; col: number } | null = null;
|
|
3292
|
+
if (cursorPos !== null) {
|
|
3293
|
+
if (cursorPos.row < chunkTo) {
|
|
3294
|
+
paintCursorPos = cursorPos;
|
|
3295
|
+
} else if (cursorPos.row >= windowTop && cursorPos.row < windowTop + height) {
|
|
3296
|
+
paintCursorPos = { row: chunkTo + cursorPos.row - windowTop, col: cursorPos.col };
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
// ConPTY hosts bound the replay: merge prefix + window into one array
|
|
3300
|
+
// so #truncateLargeConptyFrame can measure the payload and retain only
|
|
3301
|
+
// the tail. Gated on the host check — everywhere else the merge would
|
|
3302
|
+
// copy a pointer per committed row (a 50k-row session = 50k-entry
|
|
3303
|
+
// array per resize step / theme change / session replace) just to be
|
|
3304
|
+
// returned unchanged. `paintLines` stays null unless truncation
|
|
3305
|
+
// actually rewrote the replay.
|
|
3306
|
+
let paintLines: string[] | null = null;
|
|
3307
|
+
let paintLineCount = chunkTo + height;
|
|
3308
|
+
if (isConPTYHosted()) {
|
|
3309
|
+
const merged = new Array<string>(chunkTo + height);
|
|
3310
|
+
for (let i = 0; i < chunkTo; i++) merged[i] = frame[i] ?? "";
|
|
3311
|
+
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
3312
|
+
merged[chunkTo + screenRow] = window[screenRow] ?? "";
|
|
3313
|
+
}
|
|
3314
|
+
const paint = this.#truncateLargeConptyFrame(merged, width, height, paintCursorPos);
|
|
3315
|
+
if (paint.lines !== merged) {
|
|
3316
|
+
paintLines = paint.lines;
|
|
3317
|
+
paintLineCount = paint.lines.length;
|
|
3318
|
+
paintCursorPos = paint.cursorPos;
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3205
3321
|
let buffer = this.#paintBeginSequence + this.#leaveResizeAltSequence() + purgeSequence;
|
|
3206
3322
|
if (options.clearScrollback) {
|
|
3207
3323
|
buffer += "\x1b[2J\x1b[H\x1b[3J";
|
|
@@ -3218,21 +3334,43 @@ export class TUI extends Container {
|
|
|
3218
3334
|
// DECCARA fills optimize only the rows that stay visible; history-bound
|
|
3219
3335
|
// rows are written as full styled strings (their background must
|
|
3220
3336
|
// survive in scrollback, which DECCARA cannot reach).
|
|
3221
|
-
const
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3337
|
+
const visibleStart = Math.max(0, paintLineCount - height);
|
|
3338
|
+
let fillSequence = "";
|
|
3339
|
+
let visibleTexts: string[] | null = null;
|
|
3340
|
+
if (this.#deccaraFillsEnabled() && visibleStart < paintLineCount) {
|
|
3341
|
+
// Untruncated, the visible slice is exactly the caller's window
|
|
3342
|
+
// (visibleStart === chunkTo) — reuse it rather than copying;
|
|
3343
|
+
// planDeccaraFills fills its own `texts` and never mutates input.
|
|
3344
|
+
let visible = window;
|
|
3345
|
+
if (paintLines !== null) {
|
|
3346
|
+
visible = new Array<string>(paintLineCount - visibleStart);
|
|
3347
|
+
for (let k = 0; k < visible.length; k++) visible[k] = paintLines[visibleStart + k] ?? "";
|
|
3348
|
+
}
|
|
3349
|
+
const plan = planDeccaraFills(visible, width);
|
|
3350
|
+
visibleTexts = plan.texts;
|
|
3351
|
+
fillSequence = plan.sequence;
|
|
3352
|
+
}
|
|
3353
|
+
if (paintLines === null) {
|
|
3354
|
+
// Common path: emit straight from the source arrays (the
|
|
3355
|
+
// pre-merge two-loop form); byte-identical to replaying the
|
|
3356
|
+
// merged array.
|
|
3357
|
+
for (let i = 0; i < chunkTo; i++) {
|
|
3358
|
+
if (i > 0) buffer += "\r\n";
|
|
3359
|
+
buffer += this.#terminalLine(frame[i] ?? "");
|
|
3360
|
+
}
|
|
3361
|
+
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
3362
|
+
if (chunkTo + screenRow > 0) buffer += "\r\n";
|
|
3363
|
+
buffer += this.#terminalLine(visibleTexts ? (visibleTexts[screenRow] ?? "") : (window[screenRow] ?? ""));
|
|
3364
|
+
}
|
|
3365
|
+
} else {
|
|
3366
|
+
for (let i = 0; i < paintLines.length; i++) {
|
|
3367
|
+
if (i > 0) buffer += "\r\n";
|
|
3368
|
+
buffer += this.#terminalLine(
|
|
3369
|
+
visibleTexts && i >= visibleStart ? visibleTexts[i - visibleStart] : (paintLines[i] ?? ""),
|
|
3370
|
+
);
|
|
3371
|
+
}
|
|
3234
3372
|
}
|
|
3235
|
-
buffer +=
|
|
3373
|
+
buffer += fillSequence;
|
|
3236
3374
|
// Park the hardware cursor at real content bottom, not the padded
|
|
3237
3375
|
// window bottom — a later height shrink would otherwise scroll live
|
|
3238
3376
|
// rows into scrollback and duplicate them per resize step.
|
|
@@ -3240,14 +3378,30 @@ export class TUI extends Container {
|
|
|
3240
3378
|
const parkUp = height - contentRows;
|
|
3241
3379
|
if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
|
|
3242
3380
|
const contentBottomRow = windowTop + contentRows - 1;
|
|
3243
|
-
const
|
|
3381
|
+
const paintContentBottomRow = Math.max(0, paintLineCount - 1 - parkUp);
|
|
3382
|
+
const cursorControl = this.#cursorControlSequence(paintCursorPos, paintLineCount, paintContentBottomRow);
|
|
3244
3383
|
buffer += cursorControl.seq;
|
|
3245
3384
|
buffer += this.#paintEndSequence;
|
|
3246
3385
|
this.terminal.write(buffer);
|
|
3247
3386
|
|
|
3387
|
+
const committedCursorState = paintCursorPos
|
|
3388
|
+
? this.#targetHardwareCursorState(cursorPos, cursorTrackingLineCount)
|
|
3389
|
+
: null;
|
|
3390
|
+
const committedCursor = committedCursorState
|
|
3391
|
+
? {
|
|
3392
|
+
toRow: committedCursorState.row,
|
|
3393
|
+
state: committedCursorState,
|
|
3394
|
+
visible: committedCursorState.visible,
|
|
3395
|
+
}
|
|
3396
|
+
: {
|
|
3397
|
+
toRow: contentBottomRow,
|
|
3398
|
+
state: null,
|
|
3399
|
+
visible: cursorControl.visible,
|
|
3400
|
+
};
|
|
3401
|
+
|
|
3248
3402
|
this.#committedRows = chunkTo;
|
|
3249
3403
|
this.#windowTopRow = windowTop;
|
|
3250
|
-
this.#commit(frame, window, width, height,
|
|
3404
|
+
this.#commit(frame, window, width, height, committedCursor);
|
|
3251
3405
|
}
|
|
3252
3406
|
|
|
3253
3407
|
/**
|
|
@@ -3277,8 +3431,7 @@ export class TUI extends Container {
|
|
|
3277
3431
|
#requestResizeViewportPaint(): void {
|
|
3278
3432
|
if (this.#stopped) return;
|
|
3279
3433
|
this.#renderRequested = false;
|
|
3280
|
-
this.#
|
|
3281
|
-
this.#doRender();
|
|
3434
|
+
this.#executeRender();
|
|
3282
3435
|
if (this.#renderRequested) this.#scheduleRender();
|
|
3283
3436
|
}
|
|
3284
3437
|
|
|
@@ -3502,9 +3655,19 @@ export class TUI extends Container {
|
|
|
3502
3655
|
prevWindowTop: number;
|
|
3503
3656
|
prevHardwareCursorRow: number;
|
|
3504
3657
|
forceWindowRewrite: boolean;
|
|
3658
|
+
repaintVirtualScrollInPlace: boolean;
|
|
3659
|
+
cursorTrackingLineCount: number;
|
|
3505
3660
|
},
|
|
3506
3661
|
): void {
|
|
3507
|
-
const {
|
|
3662
|
+
const {
|
|
3663
|
+
chunkTo,
|
|
3664
|
+
windowTop,
|
|
3665
|
+
prevWindowTop,
|
|
3666
|
+
prevHardwareCursorRow,
|
|
3667
|
+
forceWindowRewrite,
|
|
3668
|
+
repaintVirtualScrollInPlace,
|
|
3669
|
+
cursorTrackingLineCount,
|
|
3670
|
+
} = options;
|
|
3508
3671
|
const chunkFrom = this.#committedRows;
|
|
3509
3672
|
const chunkLength = chunkTo - chunkFrom;
|
|
3510
3673
|
const scroll = windowTop - prevWindowTop;
|
|
@@ -3555,7 +3718,7 @@ export class TUI extends Container {
|
|
|
3555
3718
|
}
|
|
3556
3719
|
cursorFromRow = windowTop + lastChanged;
|
|
3557
3720
|
}
|
|
3558
|
-
const cursorControl = this.#cursorControlSequence(cursorPos,
|
|
3721
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, cursorTrackingLineCount, cursorFromRow);
|
|
3559
3722
|
buffer += cursorControl.seq;
|
|
3560
3723
|
buffer += this.#paintEndSequence;
|
|
3561
3724
|
this.terminal.write(buffer);
|
|
@@ -3566,12 +3729,17 @@ export class TUI extends Container {
|
|
|
3566
3729
|
}
|
|
3567
3730
|
}
|
|
3568
3731
|
|
|
3569
|
-
// In-window diff: nothing
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3732
|
+
// In-window diff: nothing commits. While an overlay is visible, repaint
|
|
3733
|
+
// the full viewport in place from a top-clamped cursor origin. Overlay
|
|
3734
|
+
// cursor-only frames can leave the tracked row behind the physical cursor;
|
|
3735
|
+
// a relative partial rewrite from that stale origin can CRLF on the bottom
|
|
3736
|
+
// row and scroll native history without appending to the commit tape.
|
|
3737
|
+
const overlayInPlaceRewrite = repaintVirtualScrollInPlace;
|
|
3738
|
+
if (chunkLength === 0 && (scroll === 0 || overlayInPlaceRewrite)) {
|
|
3739
|
+
if (forceWindowRewrite || overlayInPlaceRewrite) this.#fullRedrawCount += 1;
|
|
3740
|
+
let firstChanged = forceWindowRewrite || overlayInPlaceRewrite ? 0 : -1;
|
|
3741
|
+
let lastChanged = forceWindowRewrite || overlayInPlaceRewrite ? height - 1 : -1;
|
|
3742
|
+
if (!forceWindowRewrite && !overlayInPlaceRewrite) {
|
|
3575
3743
|
const comparable = previousWindow.length === height;
|
|
3576
3744
|
for (let r = 0; r < height; r++) {
|
|
3577
3745
|
if (comparable && (window[r] ?? "") === (previousWindow[r] ?? "")) continue;
|
|
@@ -3581,15 +3749,22 @@ export class TUI extends Container {
|
|
|
3581
3749
|
}
|
|
3582
3750
|
if (firstChanged === -1) {
|
|
3583
3751
|
if (purgeSequence.length > 0) this.terminal.write(purgeSequence);
|
|
3584
|
-
this.#writeCursorPosition(cursorPos,
|
|
3752
|
+
this.#writeCursorPosition(cursorPos, cursorTrackingLineCount);
|
|
3585
3753
|
this.#previousWidth = width;
|
|
3586
3754
|
this.#previousHeight = height;
|
|
3587
3755
|
return;
|
|
3588
3756
|
}
|
|
3589
3757
|
let buffer = this.#paintBeginSequence + purgeSequence;
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3758
|
+
if (overlayInPlaceRewrite) {
|
|
3759
|
+
// The cursor tracker can be stale after overlay-only frames. A large
|
|
3760
|
+
// CUU clamps at the viewport top without using absolute cursor home,
|
|
3761
|
+
// so the following full-window rewrite cannot overflow the bottom.
|
|
3762
|
+
if (height > 1) buffer += `\x1b[${height - 1}A`;
|
|
3763
|
+
} else {
|
|
3764
|
+
const rowDelta = firstChanged - currentScreenRow;
|
|
3765
|
+
if (rowDelta > 0) buffer += `\x1b[${rowDelta}B`;
|
|
3766
|
+
else if (rowDelta < 0) buffer += `\x1b[${-rowDelta}A`;
|
|
3767
|
+
}
|
|
3593
3768
|
buffer += "\r";
|
|
3594
3769
|
// DECCARA-optimize the contiguous rewritten range (visible rows
|
|
3595
3770
|
// only; rectangles are absolute screen rows).
|
|
@@ -3615,10 +3790,11 @@ export class TUI extends Container {
|
|
|
3615
3790
|
buffer += `\x1b[${lastChanged - contentBottomScreenRow}A`;
|
|
3616
3791
|
cursorFromRow = contentBottomRow;
|
|
3617
3792
|
}
|
|
3618
|
-
const cursorControl = this.#cursorControlSequence(cursorPos,
|
|
3793
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, cursorTrackingLineCount, cursorFromRow);
|
|
3619
3794
|
buffer += cursorControl.seq;
|
|
3620
3795
|
buffer += this.#paintEndSequence;
|
|
3621
3796
|
this.terminal.write(buffer);
|
|
3797
|
+
this.#windowTopRow = windowTop;
|
|
3622
3798
|
this.#commit(frame, window, width, height, cursorControl);
|
|
3623
3799
|
return;
|
|
3624
3800
|
}
|
|
@@ -3644,7 +3820,7 @@ export class TUI extends Container {
|
|
|
3644
3820
|
}
|
|
3645
3821
|
const parkUp = height - 1 - (contentBottomRow - windowTop);
|
|
3646
3822
|
if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
|
|
3647
|
-
const cursorControl = this.#cursorControlSequence(cursorPos,
|
|
3823
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, cursorTrackingLineCount, contentBottomRow);
|
|
3648
3824
|
buffer += cursorControl.seq;
|
|
3649
3825
|
buffer += this.#paintEndSequence;
|
|
3650
3826
|
this.terminal.write(buffer);
|
package/src/utils.ts
CHANGED
|
@@ -143,12 +143,13 @@ export function extractSegments(
|
|
|
143
143
|
|
|
144
144
|
// Pre-allocated space buffer for padding
|
|
145
145
|
const SPACE_BUFFER = " ".repeat(512);
|
|
146
|
+
const TAB_SPACES = " ".repeat(DEFAULT_TAB_WIDTH);
|
|
146
147
|
|
|
147
148
|
/*
|
|
148
149
|
* Replace tabs with the fixed display tab width for consistent rendering.
|
|
149
150
|
*/
|
|
150
151
|
export function replaceTabs(text: string): string {
|
|
151
|
-
return text.replaceAll("\t",
|
|
152
|
+
return text.replaceAll("\t", TAB_SPACES);
|
|
152
153
|
}
|
|
153
154
|
|
|
154
155
|
/**
|