@code-yeongyu/senpi-tui 2026.10.10 → 2026.10.11
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/dist/tui.d.ts +18 -0
- package/dist/tui.js +170 -8
- package/package.json +1 -1
package/dist/tui.d.ts
CHANGED
|
@@ -466,6 +466,16 @@ export declare abstract class TuiBase extends Container {
|
|
|
466
466
|
setTerminalColorSchemeNotifications(enabled: boolean): void;
|
|
467
467
|
private queryCellSize;
|
|
468
468
|
stop(options?: TuiStopOptions): void;
|
|
469
|
+
/**
|
|
470
|
+
* While a reply streams, a frame that changes rows above the viewport would replay the whole scrollback
|
|
471
|
+
* (ESC[3J and a full rewrite), which throws a user who scrolled up back to the top (#2836). With the hold
|
|
472
|
+
* on, such a frame repaints only the viewport and leaves the off-screen rows stale. The terminal cannot
|
|
473
|
+
* report its scroll position in main-screen mode, so the catch-up replay waits for a moment the user is
|
|
474
|
+
* surely at the bottom: their next keypress, or `catchUpScrollback()` (the next turn start).
|
|
475
|
+
*/
|
|
476
|
+
setScrollbackReplayHold(hold: boolean | "until-input"): void;
|
|
477
|
+
/** Replays the scrollback once if a held frame left rows above the viewport stale. */
|
|
478
|
+
catchUpScrollback(): void;
|
|
469
479
|
renderNow(force?: boolean): void;
|
|
470
480
|
requestRender(force?: boolean, source?: string): void;
|
|
471
481
|
/**
|
|
@@ -539,6 +549,14 @@ export declare abstract class TuiBase extends Container {
|
|
|
539
549
|
private createViewportInsertScrollPlan;
|
|
540
550
|
private renderViewportInsertScroll;
|
|
541
551
|
private renderScrollbackReplay;
|
|
552
|
+
/**
|
|
553
|
+
* The held frame of `setScrollbackReplayHold` (#2836): rows above the old viewport stay as the terminal
|
|
554
|
+
* shows them (possibly stale), and the frame is rewritten from the old viewport top down. Rows the
|
|
555
|
+
* document grew by scroll naturally into scrollback in their current form, so nothing is lost there and
|
|
556
|
+
* nothing is cleared, and a reader who scrolled up keeps their place. Returns false when an image row is
|
|
557
|
+
* involved, so the caller replays as before.
|
|
558
|
+
*/
|
|
559
|
+
private renderHeldRepaint;
|
|
542
560
|
private renderMuxViewportRepaint;
|
|
543
561
|
/** Splice overlay content into a base line at a specific column. Single-pass optimized. */
|
|
544
562
|
private compositeLineAt;
|
package/dist/tui.js
CHANGED
|
@@ -624,9 +624,15 @@ export function isViewportTUI(tui) {
|
|
|
624
624
|
return tui[VIEWPORT_TUI] === true;
|
|
625
625
|
}
|
|
626
626
|
export class TuiBase extends Container {
|
|
627
|
+
/** A multiplexer pane regained focus: the next frame repaints its viewport once (senpi#1704). */
|
|
628
|
+
#muxViewportRepaintPending;
|
|
627
629
|
/** Minimum interval between scheduled renders. Default preserves the historic ~60fps cap. */
|
|
628
630
|
#minRenderIntervalMs;
|
|
629
631
|
#lastCursorVisibility;
|
|
632
|
+
#holdScrollbackReplay;
|
|
633
|
+
#releaseHoldOnInput;
|
|
634
|
+
#scrollbackStale;
|
|
635
|
+
#scrollbackCatchUpPending;
|
|
630
636
|
get hasOverlayEntries() {
|
|
631
637
|
return this.overlayStack.length > 0;
|
|
632
638
|
}
|
|
@@ -637,6 +643,8 @@ export class TuiBase extends Container {
|
|
|
637
643
|
this.previousRawLines = [];
|
|
638
644
|
this.normalizeMemo = new Map();
|
|
639
645
|
this.previousKittyImageIds = new Set();
|
|
646
|
+
/** A multiplexer pane regained focus: the next frame repaints its viewport once (senpi#1704). */
|
|
647
|
+
this.#muxViewportRepaintPending = false;
|
|
640
648
|
this.previousWidth = 0;
|
|
641
649
|
this.previousHeight = 0;
|
|
642
650
|
this.focusedComponent = null;
|
|
@@ -663,6 +671,10 @@ export class TuiBase extends Container {
|
|
|
663
671
|
this.pendingTerminalColorQueries = [];
|
|
664
672
|
this.terminalColorSchemeListeners = new Set();
|
|
665
673
|
this.terminalColorSchemeNotificationsEnabled = false;
|
|
674
|
+
this.#holdScrollbackReplay = false;
|
|
675
|
+
this.#releaseHoldOnInput = false;
|
|
676
|
+
this.#scrollbackStale = false;
|
|
677
|
+
this.#scrollbackCatchUpPending = false;
|
|
666
678
|
// Overlay stack for modal components rendered on top of base content
|
|
667
679
|
this.focusOrderCounter = 0;
|
|
668
680
|
this.overlayStack = [];
|
|
@@ -1260,6 +1272,11 @@ export class TuiBase extends Container {
|
|
|
1260
1272
|
}
|
|
1261
1273
|
stop(options = {}) {
|
|
1262
1274
|
this.stopped = true;
|
|
1275
|
+
// The hold itself belongs to the turn (interactive mode sets and releases it); a stop()/start() handover
|
|
1276
|
+
// for an external editor or a suspend in the middle of a turn must not drop it.
|
|
1277
|
+
this.#scrollbackStale = false;
|
|
1278
|
+
this.#scrollbackCatchUpPending = false;
|
|
1279
|
+
this.#muxViewportRepaintPending = false;
|
|
1263
1280
|
this.renderRequested = false;
|
|
1264
1281
|
this.inputRenderPending = false;
|
|
1265
1282
|
this.cancelRenderTimer();
|
|
@@ -1303,6 +1320,25 @@ export class TuiBase extends Container {
|
|
|
1303
1320
|
this.maxLinesRendered = 0;
|
|
1304
1321
|
this.previousViewportTop = 0;
|
|
1305
1322
|
}
|
|
1323
|
+
/**
|
|
1324
|
+
* While a reply streams, a frame that changes rows above the viewport would replay the whole scrollback
|
|
1325
|
+
* (ESC[3J and a full rewrite), which throws a user who scrolled up back to the top (#2836). With the hold
|
|
1326
|
+
* on, such a frame repaints only the viewport and leaves the off-screen rows stale. The terminal cannot
|
|
1327
|
+
* report its scroll position in main-screen mode, so the catch-up replay waits for a moment the user is
|
|
1328
|
+
* surely at the bottom: their next keypress, or `catchUpScrollback()` (the next turn start).
|
|
1329
|
+
*/
|
|
1330
|
+
setScrollbackReplayHold(hold) {
|
|
1331
|
+
this.#holdScrollbackReplay = hold !== false;
|
|
1332
|
+
this.#releaseHoldOnInput = hold === "until-input";
|
|
1333
|
+
}
|
|
1334
|
+
/** Replays the scrollback once if a held frame left rows above the viewport stale. */
|
|
1335
|
+
catchUpScrollback() {
|
|
1336
|
+
if (!this.#scrollbackStale)
|
|
1337
|
+
return;
|
|
1338
|
+
this.#scrollbackStale = false;
|
|
1339
|
+
this.#scrollbackCatchUpPending = true;
|
|
1340
|
+
this.requestRender();
|
|
1341
|
+
}
|
|
1306
1342
|
renderNow(force = false) {
|
|
1307
1343
|
if (force)
|
|
1308
1344
|
this.resetForcedRenderState();
|
|
@@ -1366,6 +1402,7 @@ export class TuiBase extends Container {
|
|
|
1366
1402
|
}
|
|
1367
1403
|
/** Drop every cached frame so the next render repaints from a clean slate. */
|
|
1368
1404
|
resetForcedRenderState() {
|
|
1405
|
+
this.#scrollbackCatchUpPending = false;
|
|
1369
1406
|
this.resetRenderState();
|
|
1370
1407
|
this.dropPreviousLines();
|
|
1371
1408
|
this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
|
|
@@ -1422,9 +1459,22 @@ export class TuiBase extends Container {
|
|
|
1422
1459
|
if (this.mode !== "fullscreen") {
|
|
1423
1460
|
const focus = consumeTmuxFocusEvent(data);
|
|
1424
1461
|
if (focus.event !== null) {
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1462
|
+
if (this.shouldPreserveMuxScrollback()) {
|
|
1463
|
+
// senpi#1704: a forced render re-emitted the whole transcript into the pane's history on every
|
|
1464
|
+
// focus event. A pane losing focus is not visible, so it repaints nothing; a pane gaining focus
|
|
1465
|
+
// refreshes the terminal capabilities and repaints its viewport only.
|
|
1466
|
+
if (focus.event === "in") {
|
|
1467
|
+
resetCapabilitiesCache();
|
|
1468
|
+
this.invalidate();
|
|
1469
|
+
this.#muxViewportRepaintPending = true;
|
|
1470
|
+
this.requestRender();
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
else {
|
|
1474
|
+
resetCapabilitiesCache();
|
|
1475
|
+
this.invalidate();
|
|
1476
|
+
this.requestRender(true);
|
|
1477
|
+
}
|
|
1428
1478
|
if (focus.data.length === 0)
|
|
1429
1479
|
return;
|
|
1430
1480
|
data = focus.data;
|
|
@@ -1436,6 +1486,15 @@ export class TuiBase extends Container {
|
|
|
1436
1486
|
if (this.consumeTerminalColorSchemeReport(data)) {
|
|
1437
1487
|
return;
|
|
1438
1488
|
}
|
|
1489
|
+
// A key press means the user is at the bottom again. Terminal reports (mouse/wheel, OSC/DCS replies,
|
|
1490
|
+
// DEC private reports, window and cell-size reports) are not the user and must not trigger it.
|
|
1491
|
+
if (!isTerminalReport(data)) {
|
|
1492
|
+
if (this.#releaseHoldOnInput) {
|
|
1493
|
+
this.#releaseHoldOnInput = false;
|
|
1494
|
+
this.#holdScrollbackReplay = false;
|
|
1495
|
+
}
|
|
1496
|
+
this.catchUpScrollback();
|
|
1497
|
+
}
|
|
1439
1498
|
if (this.inputListeners.size > 0) {
|
|
1440
1499
|
let current = data;
|
|
1441
1500
|
for (const listener of this.inputListeners) {
|
|
@@ -2110,6 +2169,7 @@ export class TuiBase extends Container {
|
|
|
2110
2169
|
this.placementEpoch++;
|
|
2111
2170
|
}
|
|
2112
2171
|
renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow) {
|
|
2172
|
+
this.#scrollbackStale = false;
|
|
2113
2173
|
let buffer = TUI.FRAME_BEGIN;
|
|
2114
2174
|
buffer += this.deleteKittyImages(this.previousKittyImageIds);
|
|
2115
2175
|
if (!this.shouldPreserveMuxScrollback()) {
|
|
@@ -2137,16 +2197,59 @@ export class TuiBase extends Container {
|
|
|
2137
2197
|
this.previousHeight = height;
|
|
2138
2198
|
this.placementEpoch++;
|
|
2139
2199
|
}
|
|
2140
|
-
|
|
2200
|
+
/**
|
|
2201
|
+
* The held frame of `setScrollbackReplayHold` (#2836): rows above the old viewport stay as the terminal
|
|
2202
|
+
* shows them (possibly stale), and the frame is rewritten from the old viewport top down. Rows the
|
|
2203
|
+
* document grew by scroll naturally into scrollback in their current form, so nothing is lost there and
|
|
2204
|
+
* nothing is cleared, and a reader who scrolled up keeps their place. Returns false when an image row is
|
|
2205
|
+
* involved, so the caller replays as before.
|
|
2206
|
+
*/
|
|
2207
|
+
renderHeldRepaint(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow) {
|
|
2208
|
+
const start = Math.min(prevViewportTop, Math.max(0, newLines.length - height));
|
|
2209
|
+
const rowCount = Math.max(height, newLines.length - start);
|
|
2210
|
+
const rows = Array.from({ length: rowCount }, (_, index) => newLines[start + index] ?? "");
|
|
2211
|
+
const previousVisible = this.getViewportRows(this.previousLines, prevViewportTop, height);
|
|
2212
|
+
if (rows.some(isImageLine) || previousVisible.some(isImageLine))
|
|
2213
|
+
return false;
|
|
2214
|
+
let buffer = TUI.FRAME_BEGIN;
|
|
2215
|
+
const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
|
|
2216
|
+
if (currentScreenRow > 0)
|
|
2217
|
+
buffer += `\x1b[${currentScreenRow}A`;
|
|
2218
|
+
for (let row = 0; row < rows.length; row++) {
|
|
2219
|
+
if (row > 0)
|
|
2220
|
+
buffer += "\r\n";
|
|
2221
|
+
buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`;
|
|
2222
|
+
buffer += rows[row];
|
|
2223
|
+
}
|
|
2224
|
+
const lastRow = start + rows.length - 1;
|
|
2225
|
+
buffer = this.finishFrame(buffer, cursorPos, newLines.length, lastRow);
|
|
2226
|
+
writeBounded(this.terminal, buffer);
|
|
2227
|
+
this.#scrollbackStale = true;
|
|
2228
|
+
this.cursorRow = Math.max(0, newLines.length - 1);
|
|
2229
|
+
this.maxLinesRendered = newLines.length;
|
|
2230
|
+
this.previousViewportTop = Math.max(0, lastRow + 1 - height);
|
|
2231
|
+
this.setPreviousLines(newLines, rawLines);
|
|
2232
|
+
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
|
2233
|
+
this.previousWidth = width;
|
|
2234
|
+
this.previousHeight = height;
|
|
2235
|
+
this.placementEpoch++;
|
|
2236
|
+
return true;
|
|
2237
|
+
}
|
|
2238
|
+
renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height, viewportTop = Math.max(0, newLines.length - height), home = "relative") {
|
|
2141
2239
|
const previousVisible = this.getViewportRows(this.previousLines, this.previousViewportTop, height);
|
|
2142
2240
|
const nextVisible = this.getViewportRows(newLines, viewportTop, height);
|
|
2143
2241
|
if (previousVisible.some(isImageLine) || nextVisible.some(isImageLine)) {
|
|
2144
2242
|
return false;
|
|
2145
2243
|
}
|
|
2146
2244
|
let buffer = TUI.FRAME_BEGIN;
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2245
|
+
if (home === "absolute") {
|
|
2246
|
+
buffer += "\x1b[H";
|
|
2247
|
+
}
|
|
2248
|
+
else {
|
|
2249
|
+
const currentScreenRow = Math.max(0, Math.min(height - 1, this.hardwareCursorRow - this.previousViewportTop));
|
|
2250
|
+
if (currentScreenRow > 0) {
|
|
2251
|
+
buffer += `\x1b[${currentScreenRow}A`;
|
|
2252
|
+
}
|
|
2150
2253
|
}
|
|
2151
2254
|
for (let row = 0; row < height; row++) {
|
|
2152
2255
|
if (row > 0)
|
|
@@ -2293,11 +2396,24 @@ export class TuiBase extends Container {
|
|
|
2293
2396
|
const normalizedLines = this.applyViewportLineResets(rawLines, prevViewportTop, height, !widthChanged && !heightChanged);
|
|
2294
2397
|
newLines = normalizedLines.lines;
|
|
2295
2398
|
const preserveMuxScrollback = this.shouldPreserveMuxScrollback();
|
|
2399
|
+
if (this.#scrollbackCatchUpPending) {
|
|
2400
|
+
this.#scrollbackCatchUpPending = false;
|
|
2401
|
+
if (!preserveMuxScrollback && !widthChanged && !heightChanged) {
|
|
2402
|
+
this.renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow);
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
2405
|
+
// A resize frame takes its own path below; if that path does not rewrite the scrollback, the
|
|
2406
|
+
// rows are still stale and the next key press catches up.
|
|
2407
|
+
if (!preserveMuxScrollback)
|
|
2408
|
+
this.#scrollbackStale = true;
|
|
2409
|
+
}
|
|
2296
2410
|
// Helper to clear scrollback and viewport and render all new lines
|
|
2297
2411
|
const fullRender = (clear, clearScrollback = clear) => {
|
|
2298
2412
|
this.fullRedrawCount += 1;
|
|
2299
2413
|
let buffer = TUI.FRAME_BEGIN;
|
|
2300
2414
|
if (clear) {
|
|
2415
|
+
if (clearScrollback)
|
|
2416
|
+
this.#scrollbackStale = false;
|
|
2301
2417
|
buffer += this.deleteKittyImages(this.previousKittyImageIds);
|
|
2302
2418
|
buffer += "\x1b[2J\x1b[H";
|
|
2303
2419
|
if (clearScrollback && !preserveMuxScrollback && process.platform !== "win32") {
|
|
@@ -2362,11 +2478,37 @@ export class TuiBase extends Container {
|
|
|
2362
2478
|
}
|
|
2363
2479
|
// Width changes always need a full re-render because wrapping changes.
|
|
2364
2480
|
if (widthChanged) {
|
|
2481
|
+
// A width change (or a forced render) repaints everything below; a focus repaint queued in the
|
|
2482
|
+
// same tick is satisfied by it.
|
|
2483
|
+
this.#muxViewportRepaintPending = false;
|
|
2365
2484
|
logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);
|
|
2366
|
-
// In
|
|
2485
|
+
// In a multiplexer only the re-wrapped viewport is repainted: re-emitting every line of the buffer scrolled a
|
|
2486
|
+
// copy of the whole transcript into the pane's history on each width change (senpi#1704). Rows already in
|
|
2487
|
+
// that history keep their old wrapping. The pane re-wrapped the screen itself, so the repaint homes there.
|
|
2488
|
+
if (preserveMuxScrollback && this.previousWidth > 0) {
|
|
2489
|
+
if (this.renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height, undefined, "absolute"))
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2367
2492
|
fullRender(true, !preserveMuxScrollback);
|
|
2368
2493
|
return;
|
|
2369
2494
|
}
|
|
2495
|
+
if (this.#muxViewportRepaintPending) {
|
|
2496
|
+
this.#muxViewportRepaintPending = false;
|
|
2497
|
+
if (preserveMuxScrollback && !heightChanged) {
|
|
2498
|
+
logRedraw("multiplexer pane focus regained");
|
|
2499
|
+
// A frame queued before the focus event may have grown the content: follow it exactly as an
|
|
2500
|
+
// ordinary frame does, so rows added below (the editor, the status line) stay on screen.
|
|
2501
|
+
// After a shrink the viewport keeps its top (as the deleted-lines path does), so the repaint does
|
|
2502
|
+
// not scroll rows already in the pane's history into view a second time.
|
|
2503
|
+
const focusViewportTop = newLines.length > prevViewportTop
|
|
2504
|
+
? Math.max(prevViewportTop, newLines.length - height)
|
|
2505
|
+
: Math.max(0, newLines.length - height);
|
|
2506
|
+
if (!this.renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height, focusViewportTop)) {
|
|
2507
|
+
fullRender(true, false);
|
|
2508
|
+
}
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2370
2512
|
// Height changes normally need a full re-render to keep the visible viewport aligned,
|
|
2371
2513
|
// but Termux changes height when the software keyboard shows or hides.
|
|
2372
2514
|
// In that environment, a full redraw causes the entire history to replay on every toggle.
|
|
@@ -2514,6 +2656,10 @@ export class TuiBase extends Container {
|
|
|
2514
2656
|
fullRender(true, false);
|
|
2515
2657
|
}
|
|
2516
2658
|
}
|
|
2659
|
+
else if (this.#holdScrollbackReplay &&
|
|
2660
|
+
this.renderHeldRepaint(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow)) {
|
|
2661
|
+
// Held: rows above the old viewport stay stale until the catch-up replay.
|
|
2662
|
+
}
|
|
2517
2663
|
else {
|
|
2518
2664
|
this.renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow);
|
|
2519
2665
|
}
|
|
@@ -2539,6 +2685,10 @@ export class TuiBase extends Container {
|
|
|
2539
2685
|
fullRender(true, false);
|
|
2540
2686
|
}
|
|
2541
2687
|
}
|
|
2688
|
+
else if (this.#holdScrollbackReplay &&
|
|
2689
|
+
this.renderHeldRepaint(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow)) {
|
|
2690
|
+
// Held: rows above the old viewport stay stale until the catch-up replay.
|
|
2691
|
+
}
|
|
2542
2692
|
else {
|
|
2543
2693
|
this.renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow);
|
|
2544
2694
|
}
|
|
@@ -2793,6 +2943,18 @@ export class TuiBase extends Container {
|
|
|
2793
2943
|
});
|
|
2794
2944
|
}
|
|
2795
2945
|
}
|
|
2946
|
+
/**
|
|
2947
|
+
* Input that the terminal sends on its own rather than the user typing: mouse reports, OSC/DCS/APC
|
|
2948
|
+
* replies, DEC private reports (`ESC[?...`) and window/cell-size reports (`ESC[...t`). OSC/DCS/APC need
|
|
2949
|
+
* a body after the introducer, so a legacy Alt+] / Alt+Shift+P / Alt+_ key press is still a key.
|
|
2950
|
+
*/
|
|
2951
|
+
function isTerminalReport(data) {
|
|
2952
|
+
return (data.startsWith("\x1b[<") ||
|
|
2953
|
+
data.startsWith("\x1b[M") ||
|
|
2954
|
+
(data.length > 2 && (data.startsWith("\x1b]") || data.startsWith("\x1bP") || data.startsWith("\x1b_"))) ||
|
|
2955
|
+
data.startsWith("\x1b[?") ||
|
|
2956
|
+
/^\x1b\[\d+(;\d+)*t$/.test(data));
|
|
2957
|
+
}
|
|
2796
2958
|
/** Legacy main-screen renderer export. */
|
|
2797
2959
|
export class TUI extends TuiBase {
|
|
2798
2960
|
constructor() {
|
package/package.json
CHANGED