@sayknow-cli/tui 0.3.6 → 0.3.7
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/types/autocomplete.d.ts +82 -0
- package/dist/types/bracketed-paste.d.ts +26 -0
- package/dist/types/components/box.d.ts +20 -0
- package/dist/types/components/cancellable-loader.d.ts +21 -0
- package/dist/types/components/editor.d.ts +117 -0
- package/dist/types/components/image.d.ts +16 -0
- package/dist/types/components/input.d.ts +16 -0
- package/dist/types/components/loader.d.ts +14 -0
- package/dist/types/components/markdown.d.ts +64 -0
- package/dist/types/components/select-list.d.ts +46 -0
- package/dist/types/components/settings-list.d.ts +39 -0
- package/dist/types/components/spacer.d.ts +11 -0
- package/dist/types/components/tab-bar.d.ts +56 -0
- package/dist/types/components/text.d.ts +13 -0
- package/dist/types/components/truncated-text.d.ts +10 -0
- package/dist/types/editor-component.d.ts +36 -0
- package/dist/types/fuzzy.d.ts +15 -0
- package/dist/types/index.d.ts +26 -0
- package/dist/types/keybindings.d.ts +201 -0
- package/dist/types/keys.d.ts +208 -0
- package/dist/types/kill-ring.d.ts +27 -0
- package/dist/types/metrics.d.ts +85 -0
- package/dist/types/stdin-buffer.d.ts +50 -0
- package/dist/types/symbols.d.ts +23 -0
- package/dist/types/terminal-capabilities.d.ts +75 -0
- package/dist/types/terminal.d.ts +88 -0
- package/dist/types/ttyid.d.ts +9 -0
- package/dist/types/tui.d.ts +197 -0
- package/dist/types/utils.d.ts +75 -0
- package/package.json +9 -8
- package/src/components/editor.ts +58 -13
- package/src/components/input.ts +2 -1
- package/src/components/select-list.ts +8 -1
- package/src/terminal.ts +44 -8
- package/src/tui.ts +151 -2
package/src/terminal.ts
CHANGED
|
@@ -207,6 +207,7 @@ export function resolveTerminalRows(
|
|
|
207
207
|
function isWindowsSubsystemForLinux(): boolean {
|
|
208
208
|
return process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
|
|
209
209
|
}
|
|
210
|
+
const STDOUT_ERROR_HANDLER_GRACE_MS = 250;
|
|
210
211
|
|
|
211
212
|
/**
|
|
212
213
|
* Real terminal using process.stdin/stdout
|
|
@@ -225,6 +226,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
225
226
|
#detachLogPath = $env.PI_TUI_TERMINAL_DETACH_LOG || "";
|
|
226
227
|
#windowsVTInputRestore?: () => void;
|
|
227
228
|
#stdoutErrorHandler?: (err: Error) => void;
|
|
229
|
+
#stdoutErrorHandlerCleanupTimer?: Timer;
|
|
228
230
|
#appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
|
|
229
231
|
#appearance: TerminalAppearance | undefined;
|
|
230
232
|
#osc11Pending = false;
|
|
@@ -277,10 +279,16 @@ export class ProcessTerminal implements Terminal {
|
|
|
277
279
|
|
|
278
280
|
// Set up resize handler immediately
|
|
279
281
|
process.stdout.on("resize", this.#resizeHandler);
|
|
280
|
-
this.#
|
|
281
|
-
this.#
|
|
282
|
-
|
|
283
|
-
|
|
282
|
+
if (this.#stdoutErrorHandlerCleanupTimer) {
|
|
283
|
+
clearTimeout(this.#stdoutErrorHandlerCleanupTimer);
|
|
284
|
+
this.#stdoutErrorHandlerCleanupTimer = undefined;
|
|
285
|
+
}
|
|
286
|
+
if (!this.#stdoutErrorHandler) {
|
|
287
|
+
this.#stdoutErrorHandler = (err: Error) => {
|
|
288
|
+
this.#markUnavailable(err, "stdout-error");
|
|
289
|
+
};
|
|
290
|
+
process.stdout.on("error", this.#stdoutErrorHandler);
|
|
291
|
+
}
|
|
284
292
|
|
|
285
293
|
// Refresh terminal dimensions - they may be stale after suspend/resume
|
|
286
294
|
// (SIGWINCH is lost while process is stopped). Unix only.
|
|
@@ -622,6 +630,20 @@ export class ProcessTerminal implements Terminal {
|
|
|
622
630
|
return;
|
|
623
631
|
}
|
|
624
632
|
this.#safeWrite("\x1b[?u");
|
|
633
|
+
// Windows Terminal and conhost do not implement the Kitty keyboard
|
|
634
|
+
// protocol, so the query above never activates it there. They do honor the
|
|
635
|
+
// modifyOtherKeys fallback below — but that mode breaks Windows CJK/Hangul
|
|
636
|
+
// IME composition: Alt+Enter (and other chords) bypass the IME commit, so
|
|
637
|
+
// the syllable still being composed is never delivered to the app and the
|
|
638
|
+
// action fires on empty text (e.g. queue-message no-ops unless the user
|
|
639
|
+
// types a trailing space to force a commit first). Skip the fallback on
|
|
640
|
+
// win32; legacy encodings still deliver Alt+Enter (ESC CR) and the newline
|
|
641
|
+
// chords, and IME composition works again. Opt back in with
|
|
642
|
+
// SKC_TUI_KEYBOARD_PROTOCOL=0 disabling all enhancement, or force-enable
|
|
643
|
+
// elsewhere if a Kitty-capable Windows terminal appears.
|
|
644
|
+
if (process.platform === "win32") {
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
625
647
|
this.#modifyOtherKeysTimeout = setTimeout(() => {
|
|
626
648
|
this.#modifyOtherKeysTimeout = undefined;
|
|
627
649
|
if (this.#kittyProtocolActive || this.#modifyOtherKeysActive) {
|
|
@@ -736,10 +758,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
736
758
|
process.stdout.removeListener("resize", this.#resizeHandler);
|
|
737
759
|
this.#resizeHandler = undefined;
|
|
738
760
|
}
|
|
739
|
-
|
|
740
|
-
process.stdout.removeListener("error", this.#stdoutErrorHandler);
|
|
741
|
-
this.#stdoutErrorHandler = undefined;
|
|
742
|
-
}
|
|
761
|
+
this.#scheduleStdoutErrorHandlerCleanup();
|
|
743
762
|
|
|
744
763
|
// Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
|
|
745
764
|
// re-interpreted after raw mode is disabled. This fixes a race condition
|
|
@@ -752,6 +771,23 @@ export class ProcessTerminal implements Terminal {
|
|
|
752
771
|
}
|
|
753
772
|
}
|
|
754
773
|
|
|
774
|
+
#scheduleStdoutErrorHandlerCleanup(): void {
|
|
775
|
+
if (!this.#stdoutErrorHandler) return;
|
|
776
|
+
if (this.#stdoutErrorHandlerCleanupTimer) clearTimeout(this.#stdoutErrorHandlerCleanupTimer);
|
|
777
|
+
// Terminal restore writes above can fail asynchronously after stop() returns
|
|
778
|
+
// when an SSH/Windows Terminal PTY disappears. Keep the stdout error listener
|
|
779
|
+
// armed briefly so late EIO/EPIPE events mark the terminal unavailable instead
|
|
780
|
+
// of surfacing as uncaught exceptions that kill the tmux pane.
|
|
781
|
+
this.#stdoutErrorHandlerCleanupTimer = setTimeout(() => {
|
|
782
|
+
if (this.#stdoutErrorHandler) {
|
|
783
|
+
process.stdout.removeListener("error", this.#stdoutErrorHandler);
|
|
784
|
+
this.#stdoutErrorHandler = undefined;
|
|
785
|
+
}
|
|
786
|
+
this.#stdoutErrorHandlerCleanupTimer = undefined;
|
|
787
|
+
}, STDOUT_ERROR_HANDLER_GRACE_MS);
|
|
788
|
+
this.#stdoutErrorHandlerCleanupTimer.unref?.();
|
|
789
|
+
}
|
|
790
|
+
|
|
755
791
|
write(data: string): void {
|
|
756
792
|
this.#safeWrite(data);
|
|
757
793
|
if (this.#writeLogPath) {
|
package/src/tui.ts
CHANGED
|
@@ -341,6 +341,8 @@ export class TUI extends Container {
|
|
|
341
341
|
#cursorRow = 0; // Logical cursor row (end of rendered content)
|
|
342
342
|
#hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
|
343
343
|
#viewportTopRow = 0; // Content row currently mapped to screen row 0
|
|
344
|
+
#manualViewportTop: number | undefined;
|
|
345
|
+
#lastCursorPosition: { row: number; col: number } | null = null;
|
|
344
346
|
#sixelProbePendingDa = false;
|
|
345
347
|
#sixelProbePendingGraphics = false;
|
|
346
348
|
#sixelProbeBuffer = "";
|
|
@@ -427,6 +429,47 @@ export class TUI extends Container {
|
|
|
427
429
|
this.#bottomPinnedComponent = component;
|
|
428
430
|
this.requestRender();
|
|
429
431
|
}
|
|
432
|
+
scrollViewportPages(direction: -1 | 1): boolean {
|
|
433
|
+
const height = this.terminal.rows;
|
|
434
|
+
const width = this.terminal.columns;
|
|
435
|
+
if (height <= 0 || width <= 0 || this.#previousLines.length === 0) return false;
|
|
436
|
+
const maxViewportTop = Math.max(0, this.#previousLines.length - height);
|
|
437
|
+
const currentViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop ?? this.#viewportTopRow));
|
|
438
|
+
const pageStep = Math.max(1, height - 1);
|
|
439
|
+
const targetViewportTop = Math.max(0, Math.min(maxViewportTop, currentViewportTop + direction * pageStep));
|
|
440
|
+
|
|
441
|
+
if (targetViewportTop >= maxViewportTop) {
|
|
442
|
+
this.#manualViewportTop = undefined;
|
|
443
|
+
} else {
|
|
444
|
+
this.#manualViewportTop = targetViewportTop;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const cursorPos = this.#manualViewportTop === undefined ? this.#lastCursorPosition : null;
|
|
448
|
+
return this.#repaintViewportFromLines(
|
|
449
|
+
this.#previousLines,
|
|
450
|
+
width,
|
|
451
|
+
height,
|
|
452
|
+
targetViewportTop,
|
|
453
|
+
cursorPos,
|
|
454
|
+
"manual viewport scroll",
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
followLiveViewport(): boolean {
|
|
459
|
+
if (this.#manualViewportTop === undefined) return false;
|
|
460
|
+
const height = this.terminal.rows;
|
|
461
|
+
const width = this.terminal.columns;
|
|
462
|
+
const liveViewportTop = Math.max(0, this.#previousLines.length - height);
|
|
463
|
+
this.#manualViewportTop = undefined;
|
|
464
|
+
return this.#repaintViewportFromLines(
|
|
465
|
+
this.#previousLines,
|
|
466
|
+
width,
|
|
467
|
+
height,
|
|
468
|
+
liveViewportTop,
|
|
469
|
+
this.#lastCursorPosition,
|
|
470
|
+
"manual viewport follow live",
|
|
471
|
+
);
|
|
472
|
+
}
|
|
430
473
|
|
|
431
474
|
/**
|
|
432
475
|
* Show an overlay component with configurable positioning and sizing.
|
|
@@ -526,7 +569,7 @@ export class TUI extends Container {
|
|
|
526
569
|
data => this.#handleInput(data),
|
|
527
570
|
() => {
|
|
528
571
|
this.invalidate();
|
|
529
|
-
this.
|
|
572
|
+
this.requestResizeRender();
|
|
530
573
|
},
|
|
531
574
|
);
|
|
532
575
|
this.#hideCursor();
|
|
@@ -770,6 +813,22 @@ export class TUI extends Container {
|
|
|
770
813
|
this.#previousHeight = 0;
|
|
771
814
|
}
|
|
772
815
|
|
|
816
|
+
/**
|
|
817
|
+
* Multiplexer-aware resize render request.
|
|
818
|
+
*
|
|
819
|
+
* A forced full redraw (`requestRender(true)`) resets `#previousWidth`/`#previousHeight`
|
|
820
|
+
* to -1, which makes `#doRender` treat the frame as a width change and fall into the
|
|
821
|
+
* `fullRender` path. In terminal multiplexers that path skips the scrollback-clearing
|
|
822
|
+
* `3J` escape (users navigate scrollback history), so replaying every transcript line
|
|
823
|
+
* piles it back on top of scrollback — the "top of screen scrolls down to the prompt at
|
|
824
|
+
* high speed" resize storm. Here we keep force off in multiplexers so `#doRender`'s
|
|
825
|
+
* height-change branch takes the viewport-only `multiplexerViewportRepaint` path instead.
|
|
826
|
+
* Set `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy forced redraw.
|
|
827
|
+
*/
|
|
828
|
+
requestResizeRender(): void {
|
|
829
|
+
this.requestRender(!(isMultiplexerSession() && !useLegacyMultiplexerFullRender()), "resize");
|
|
830
|
+
}
|
|
831
|
+
|
|
773
832
|
requestRender(force = false, source = "unknown"): void {
|
|
774
833
|
if (!this.terminalAvailable) {
|
|
775
834
|
this.#markTerminalUnavailable();
|
|
@@ -1345,6 +1404,64 @@ export class TUI extends Container {
|
|
|
1345
1404
|
padded.splice(insertAt, 0, ...Array.from({ length: blankRows }, () => ""));
|
|
1346
1405
|
return padded;
|
|
1347
1406
|
}
|
|
1407
|
+
#repaintViewportFromLines(
|
|
1408
|
+
lines: string[],
|
|
1409
|
+
width: number,
|
|
1410
|
+
height: number,
|
|
1411
|
+
viewportTop: number,
|
|
1412
|
+
cursorPos: { row: number; col: number } | null,
|
|
1413
|
+
reason: string,
|
|
1414
|
+
): boolean {
|
|
1415
|
+
if (height <= 0 || width <= 0) return false;
|
|
1416
|
+
const maxViewportTop = Math.max(0, lines.length - height);
|
|
1417
|
+
const nextViewportTop = Math.max(0, Math.min(maxViewportTop, viewportTop));
|
|
1418
|
+
const currentScreenRow = Math.max(0, Math.min(height - 1, this.#hardwareCursorRow - this.#viewportTopRow));
|
|
1419
|
+
let buffer = "\x1b[?2026h";
|
|
1420
|
+
if (currentScreenRow > 0) {
|
|
1421
|
+
buffer += `\x1b[${currentScreenRow}A`;
|
|
1422
|
+
}
|
|
1423
|
+
buffer += "\r";
|
|
1424
|
+
|
|
1425
|
+
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
1426
|
+
if (screenRow > 0) buffer += "\r\n";
|
|
1427
|
+
buffer += "\x1b[2K";
|
|
1428
|
+
const lineIndex = nextViewportTop + screenRow;
|
|
1429
|
+
if (lineIndex >= lines.length) continue;
|
|
1430
|
+
const line = lines[lineIndex];
|
|
1431
|
+
const isImage = TERMINAL.isImageLine(line);
|
|
1432
|
+
if (!isImage && visibleWidth(line) > width) {
|
|
1433
|
+
let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
|
|
1434
|
+
truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
|
|
1435
|
+
buffer += truncatedLine;
|
|
1436
|
+
} else {
|
|
1437
|
+
buffer += line;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
const finalPhysicalRow = nextViewportTop + Math.max(0, height - 1);
|
|
1442
|
+
let cursorSeq = "\x1b[?25l";
|
|
1443
|
+
let cursorToRow = finalPhysicalRow;
|
|
1444
|
+
if (cursorPos && cursorPos.row >= nextViewportTop && cursorPos.row < nextViewportTop + height) {
|
|
1445
|
+
const cursor = this.#cursorControlSequence(cursorPos, lines.length, finalPhysicalRow);
|
|
1446
|
+
cursorSeq = cursor.seq;
|
|
1447
|
+
cursorToRow = cursor.toRow;
|
|
1448
|
+
}
|
|
1449
|
+
this.#hardwareCursorRow = cursorToRow;
|
|
1450
|
+
buffer += cursorSeq;
|
|
1451
|
+
buffer += "\x1b[?2026l";
|
|
1452
|
+
if (!this.#writeTerminal(buffer)) return false;
|
|
1453
|
+
|
|
1454
|
+
if ($flag("PI_DEBUG_REDRAW")) {
|
|
1455
|
+
const logPath = getDebugLogPath();
|
|
1456
|
+
const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
|
|
1457
|
+
fs.appendFileSync(logPath, msg);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
this.#cursorRow = Math.max(0, lines.length - 1);
|
|
1461
|
+
this.#maxLinesRendered = lines.length;
|
|
1462
|
+
this.#viewportTopRow = nextViewportTop;
|
|
1463
|
+
return true;
|
|
1464
|
+
}
|
|
1348
1465
|
|
|
1349
1466
|
#doRender(): void {
|
|
1350
1467
|
if (this.#stopped || !this.terminalAvailable) return;
|
|
@@ -1375,6 +1492,7 @@ export class TUI extends Container {
|
|
|
1375
1492
|
|
|
1376
1493
|
// Extract cursor position (marker must be found before diff comparison)
|
|
1377
1494
|
const cursorPos = this.#extractCursorPosition(newLines, height);
|
|
1495
|
+
this.#lastCursorPosition = cursorPos;
|
|
1378
1496
|
|
|
1379
1497
|
// Terminate every non-image line so #previousLines mirrors emitted bytes
|
|
1380
1498
|
// (closes SGR + OSC 8 hyperlink state). Must run after cursor extraction
|
|
@@ -1435,6 +1553,28 @@ export class TUI extends Container {
|
|
|
1435
1553
|
if (usedWindowNormalize) renderMetrics.recordLineCount("offscreenScan", diffStart);
|
|
1436
1554
|
}
|
|
1437
1555
|
|
|
1556
|
+
if (this.#manualViewportTop !== undefined) {
|
|
1557
|
+
const maxViewportTop = Math.max(0, newLines.length - height);
|
|
1558
|
+
const nextViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop));
|
|
1559
|
+
const followingLive = nextViewportTop >= maxViewportTop;
|
|
1560
|
+
this.#manualViewportTop = followingLive ? undefined : nextViewportTop;
|
|
1561
|
+
const repaintCursorPos = followingLive ? cursorPos : null;
|
|
1562
|
+
if (
|
|
1563
|
+
this.#repaintViewportFromLines(
|
|
1564
|
+
newLines,
|
|
1565
|
+
width,
|
|
1566
|
+
height,
|
|
1567
|
+
nextViewportTop,
|
|
1568
|
+
repaintCursorPos,
|
|
1569
|
+
"manual viewport render",
|
|
1570
|
+
)
|
|
1571
|
+
) {
|
|
1572
|
+
this.#previousLines = newLines;
|
|
1573
|
+
this.#previousWidth = width;
|
|
1574
|
+
this.#previousHeight = height;
|
|
1575
|
+
}
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1438
1578
|
// Helper to clear scrollback and viewport and render all new lines
|
|
1439
1579
|
const fullRender = (clear: boolean, reason = "full render"): void => {
|
|
1440
1580
|
this.#fullRedrawCount += 1;
|
|
@@ -1540,7 +1680,16 @@ export class TUI extends Container {
|
|
|
1540
1680
|
// Width changes always need a full re-render because wrapping changes.
|
|
1541
1681
|
if (widthChanged) {
|
|
1542
1682
|
logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
|
|
1543
|
-
|
|
1683
|
+
if (isMultiplexerSession() && !useLegacyMultiplexerFullRender()) {
|
|
1684
|
+
// In multiplexers a full replay piles the whole transcript back onto
|
|
1685
|
+
// scrollback (3J is intentionally skipped). Repaint the viewport only,
|
|
1686
|
+
// mirroring the height-change branch. This also neutralizes the fake
|
|
1687
|
+
// width change that requestRender(true) injects via #previousWidth = -1,
|
|
1688
|
+
// so every force-render call site is safe in multiplexers too.
|
|
1689
|
+
multiplexerViewportRepaint(`terminal width changed (${this.#previousWidth} -> ${width})`);
|
|
1690
|
+
} else {
|
|
1691
|
+
fullRender(true, "terminal width changed");
|
|
1692
|
+
}
|
|
1544
1693
|
return;
|
|
1545
1694
|
}
|
|
1546
1695
|
|