@code-yeongyu/senpi-tui 2026.10.10-4 → 2026.10.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/dist/tui.d.ts +18 -0
- package/dist/tui.js +113 -0
- 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
|
@@ -627,6 +627,10 @@ export class TuiBase extends Container {
|
|
|
627
627
|
/** Minimum interval between scheduled renders. Default preserves the historic ~60fps cap. */
|
|
628
628
|
#minRenderIntervalMs;
|
|
629
629
|
#lastCursorVisibility;
|
|
630
|
+
#holdScrollbackReplay;
|
|
631
|
+
#releaseHoldOnInput;
|
|
632
|
+
#scrollbackStale;
|
|
633
|
+
#scrollbackCatchUpPending;
|
|
630
634
|
get hasOverlayEntries() {
|
|
631
635
|
return this.overlayStack.length > 0;
|
|
632
636
|
}
|
|
@@ -663,6 +667,10 @@ export class TuiBase extends Container {
|
|
|
663
667
|
this.pendingTerminalColorQueries = [];
|
|
664
668
|
this.terminalColorSchemeListeners = new Set();
|
|
665
669
|
this.terminalColorSchemeNotificationsEnabled = false;
|
|
670
|
+
this.#holdScrollbackReplay = false;
|
|
671
|
+
this.#releaseHoldOnInput = false;
|
|
672
|
+
this.#scrollbackStale = false;
|
|
673
|
+
this.#scrollbackCatchUpPending = false;
|
|
666
674
|
// Overlay stack for modal components rendered on top of base content
|
|
667
675
|
this.focusOrderCounter = 0;
|
|
668
676
|
this.overlayStack = [];
|
|
@@ -1260,6 +1268,10 @@ export class TuiBase extends Container {
|
|
|
1260
1268
|
}
|
|
1261
1269
|
stop(options = {}) {
|
|
1262
1270
|
this.stopped = true;
|
|
1271
|
+
// The hold itself belongs to the turn (interactive mode sets and releases it); a stop()/start() handover
|
|
1272
|
+
// for an external editor or a suspend in the middle of a turn must not drop it.
|
|
1273
|
+
this.#scrollbackStale = false;
|
|
1274
|
+
this.#scrollbackCatchUpPending = false;
|
|
1263
1275
|
this.renderRequested = false;
|
|
1264
1276
|
this.inputRenderPending = false;
|
|
1265
1277
|
this.cancelRenderTimer();
|
|
@@ -1303,6 +1315,25 @@ export class TuiBase extends Container {
|
|
|
1303
1315
|
this.maxLinesRendered = 0;
|
|
1304
1316
|
this.previousViewportTop = 0;
|
|
1305
1317
|
}
|
|
1318
|
+
/**
|
|
1319
|
+
* While a reply streams, a frame that changes rows above the viewport would replay the whole scrollback
|
|
1320
|
+
* (ESC[3J and a full rewrite), which throws a user who scrolled up back to the top (#2836). With the hold
|
|
1321
|
+
* on, such a frame repaints only the viewport and leaves the off-screen rows stale. The terminal cannot
|
|
1322
|
+
* report its scroll position in main-screen mode, so the catch-up replay waits for a moment the user is
|
|
1323
|
+
* surely at the bottom: their next keypress, or `catchUpScrollback()` (the next turn start).
|
|
1324
|
+
*/
|
|
1325
|
+
setScrollbackReplayHold(hold) {
|
|
1326
|
+
this.#holdScrollbackReplay = hold !== false;
|
|
1327
|
+
this.#releaseHoldOnInput = hold === "until-input";
|
|
1328
|
+
}
|
|
1329
|
+
/** Replays the scrollback once if a held frame left rows above the viewport stale. */
|
|
1330
|
+
catchUpScrollback() {
|
|
1331
|
+
if (!this.#scrollbackStale)
|
|
1332
|
+
return;
|
|
1333
|
+
this.#scrollbackStale = false;
|
|
1334
|
+
this.#scrollbackCatchUpPending = true;
|
|
1335
|
+
this.requestRender();
|
|
1336
|
+
}
|
|
1306
1337
|
renderNow(force = false) {
|
|
1307
1338
|
if (force)
|
|
1308
1339
|
this.resetForcedRenderState();
|
|
@@ -1366,6 +1397,7 @@ export class TuiBase extends Container {
|
|
|
1366
1397
|
}
|
|
1367
1398
|
/** Drop every cached frame so the next render repaints from a clean slate. */
|
|
1368
1399
|
resetForcedRenderState() {
|
|
1400
|
+
this.#scrollbackCatchUpPending = false;
|
|
1369
1401
|
this.resetRenderState();
|
|
1370
1402
|
this.dropPreviousLines();
|
|
1371
1403
|
this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
|
|
@@ -1436,6 +1468,15 @@ export class TuiBase extends Container {
|
|
|
1436
1468
|
if (this.consumeTerminalColorSchemeReport(data)) {
|
|
1437
1469
|
return;
|
|
1438
1470
|
}
|
|
1471
|
+
// A key press means the user is at the bottom again. Terminal reports (mouse/wheel, OSC/DCS replies,
|
|
1472
|
+
// DEC private reports, window and cell-size reports) are not the user and must not trigger it.
|
|
1473
|
+
if (!isTerminalReport(data)) {
|
|
1474
|
+
if (this.#releaseHoldOnInput) {
|
|
1475
|
+
this.#releaseHoldOnInput = false;
|
|
1476
|
+
this.#holdScrollbackReplay = false;
|
|
1477
|
+
}
|
|
1478
|
+
this.catchUpScrollback();
|
|
1479
|
+
}
|
|
1439
1480
|
if (this.inputListeners.size > 0) {
|
|
1440
1481
|
let current = data;
|
|
1441
1482
|
for (const listener of this.inputListeners) {
|
|
@@ -2110,6 +2151,7 @@ export class TuiBase extends Container {
|
|
|
2110
2151
|
this.placementEpoch++;
|
|
2111
2152
|
}
|
|
2112
2153
|
renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow) {
|
|
2154
|
+
this.#scrollbackStale = false;
|
|
2113
2155
|
let buffer = TUI.FRAME_BEGIN;
|
|
2114
2156
|
buffer += this.deleteKittyImages(this.previousKittyImageIds);
|
|
2115
2157
|
if (!this.shouldPreserveMuxScrollback()) {
|
|
@@ -2137,6 +2179,44 @@ export class TuiBase extends Container {
|
|
|
2137
2179
|
this.previousHeight = height;
|
|
2138
2180
|
this.placementEpoch++;
|
|
2139
2181
|
}
|
|
2182
|
+
/**
|
|
2183
|
+
* The held frame of `setScrollbackReplayHold` (#2836): rows above the old viewport stay as the terminal
|
|
2184
|
+
* shows them (possibly stale), and the frame is rewritten from the old viewport top down. Rows the
|
|
2185
|
+
* document grew by scroll naturally into scrollback in their current form, so nothing is lost there and
|
|
2186
|
+
* nothing is cleared, and a reader who scrolled up keeps their place. Returns false when an image row is
|
|
2187
|
+
* involved, so the caller replays as before.
|
|
2188
|
+
*/
|
|
2189
|
+
renderHeldRepaint(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow) {
|
|
2190
|
+
const start = Math.min(prevViewportTop, Math.max(0, newLines.length - height));
|
|
2191
|
+
const rowCount = Math.max(height, newLines.length - start);
|
|
2192
|
+
const rows = Array.from({ length: rowCount }, (_, index) => newLines[start + index] ?? "");
|
|
2193
|
+
const previousVisible = this.getViewportRows(this.previousLines, prevViewportTop, height);
|
|
2194
|
+
if (rows.some(isImageLine) || previousVisible.some(isImageLine))
|
|
2195
|
+
return false;
|
|
2196
|
+
let buffer = TUI.FRAME_BEGIN;
|
|
2197
|
+
const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
|
|
2198
|
+
if (currentScreenRow > 0)
|
|
2199
|
+
buffer += `\x1b[${currentScreenRow}A`;
|
|
2200
|
+
for (let row = 0; row < rows.length; row++) {
|
|
2201
|
+
if (row > 0)
|
|
2202
|
+
buffer += "\r\n";
|
|
2203
|
+
buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`;
|
|
2204
|
+
buffer += rows[row];
|
|
2205
|
+
}
|
|
2206
|
+
const lastRow = start + rows.length - 1;
|
|
2207
|
+
buffer = this.finishFrame(buffer, cursorPos, newLines.length, lastRow);
|
|
2208
|
+
writeBounded(this.terminal, buffer);
|
|
2209
|
+
this.#scrollbackStale = true;
|
|
2210
|
+
this.cursorRow = Math.max(0, newLines.length - 1);
|
|
2211
|
+
this.maxLinesRendered = newLines.length;
|
|
2212
|
+
this.previousViewportTop = Math.max(0, lastRow + 1 - height);
|
|
2213
|
+
this.setPreviousLines(newLines, rawLines);
|
|
2214
|
+
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
|
2215
|
+
this.previousWidth = width;
|
|
2216
|
+
this.previousHeight = height;
|
|
2217
|
+
this.placementEpoch++;
|
|
2218
|
+
return true;
|
|
2219
|
+
}
|
|
2140
2220
|
renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height, viewportTop = Math.max(0, newLines.length - height)) {
|
|
2141
2221
|
const previousVisible = this.getViewportRows(this.previousLines, this.previousViewportTop, height);
|
|
2142
2222
|
const nextVisible = this.getViewportRows(newLines, viewportTop, height);
|
|
@@ -2293,11 +2373,24 @@ export class TuiBase extends Container {
|
|
|
2293
2373
|
const normalizedLines = this.applyViewportLineResets(rawLines, prevViewportTop, height, !widthChanged && !heightChanged);
|
|
2294
2374
|
newLines = normalizedLines.lines;
|
|
2295
2375
|
const preserveMuxScrollback = this.shouldPreserveMuxScrollback();
|
|
2376
|
+
if (this.#scrollbackCatchUpPending) {
|
|
2377
|
+
this.#scrollbackCatchUpPending = false;
|
|
2378
|
+
if (!preserveMuxScrollback && !widthChanged && !heightChanged) {
|
|
2379
|
+
this.renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow);
|
|
2380
|
+
return;
|
|
2381
|
+
}
|
|
2382
|
+
// A resize frame takes its own path below; if that path does not rewrite the scrollback, the
|
|
2383
|
+
// rows are still stale and the next key press catches up.
|
|
2384
|
+
if (!preserveMuxScrollback)
|
|
2385
|
+
this.#scrollbackStale = true;
|
|
2386
|
+
}
|
|
2296
2387
|
// Helper to clear scrollback and viewport and render all new lines
|
|
2297
2388
|
const fullRender = (clear, clearScrollback = clear) => {
|
|
2298
2389
|
this.fullRedrawCount += 1;
|
|
2299
2390
|
let buffer = TUI.FRAME_BEGIN;
|
|
2300
2391
|
if (clear) {
|
|
2392
|
+
if (clearScrollback)
|
|
2393
|
+
this.#scrollbackStale = false;
|
|
2301
2394
|
buffer += this.deleteKittyImages(this.previousKittyImageIds);
|
|
2302
2395
|
buffer += "\x1b[2J\x1b[H";
|
|
2303
2396
|
if (clearScrollback && !preserveMuxScrollback && process.platform !== "win32") {
|
|
@@ -2514,6 +2607,10 @@ export class TuiBase extends Container {
|
|
|
2514
2607
|
fullRender(true, false);
|
|
2515
2608
|
}
|
|
2516
2609
|
}
|
|
2610
|
+
else if (this.#holdScrollbackReplay &&
|
|
2611
|
+
this.renderHeldRepaint(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow)) {
|
|
2612
|
+
// Held: rows above the old viewport stay stale until the catch-up replay.
|
|
2613
|
+
}
|
|
2517
2614
|
else {
|
|
2518
2615
|
this.renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow);
|
|
2519
2616
|
}
|
|
@@ -2539,6 +2636,10 @@ export class TuiBase extends Container {
|
|
|
2539
2636
|
fullRender(true, false);
|
|
2540
2637
|
}
|
|
2541
2638
|
}
|
|
2639
|
+
else if (this.#holdScrollbackReplay &&
|
|
2640
|
+
this.renderHeldRepaint(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow)) {
|
|
2641
|
+
// Held: rows above the old viewport stay stale until the catch-up replay.
|
|
2642
|
+
}
|
|
2542
2643
|
else {
|
|
2543
2644
|
this.renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow);
|
|
2544
2645
|
}
|
|
@@ -2793,6 +2894,18 @@ export class TuiBase extends Container {
|
|
|
2793
2894
|
});
|
|
2794
2895
|
}
|
|
2795
2896
|
}
|
|
2897
|
+
/**
|
|
2898
|
+
* Input that the terminal sends on its own rather than the user typing: mouse reports, OSC/DCS/APC
|
|
2899
|
+
* replies, DEC private reports (`ESC[?...`) and window/cell-size reports (`ESC[...t`). OSC/DCS/APC need
|
|
2900
|
+
* a body after the introducer, so a legacy Alt+] / Alt+Shift+P / Alt+_ key press is still a key.
|
|
2901
|
+
*/
|
|
2902
|
+
function isTerminalReport(data) {
|
|
2903
|
+
return (data.startsWith("\x1b[<") ||
|
|
2904
|
+
data.startsWith("\x1b[M") ||
|
|
2905
|
+
(data.length > 2 && (data.startsWith("\x1b]") || data.startsWith("\x1bP") || data.startsWith("\x1b_"))) ||
|
|
2906
|
+
data.startsWith("\x1b[?") ||
|
|
2907
|
+
/^\x1b\[\d+(;\d+)*t$/.test(data));
|
|
2908
|
+
}
|
|
2796
2909
|
/** Legacy main-screen renderer export. */
|
|
2797
2910
|
export class TUI extends TuiBase {
|
|
2798
2911
|
constructor() {
|
package/package.json
CHANGED