@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.
Files changed (35) hide show
  1. package/dist/types/autocomplete.d.ts +82 -0
  2. package/dist/types/bracketed-paste.d.ts +26 -0
  3. package/dist/types/components/box.d.ts +20 -0
  4. package/dist/types/components/cancellable-loader.d.ts +21 -0
  5. package/dist/types/components/editor.d.ts +117 -0
  6. package/dist/types/components/image.d.ts +16 -0
  7. package/dist/types/components/input.d.ts +16 -0
  8. package/dist/types/components/loader.d.ts +14 -0
  9. package/dist/types/components/markdown.d.ts +64 -0
  10. package/dist/types/components/select-list.d.ts +46 -0
  11. package/dist/types/components/settings-list.d.ts +39 -0
  12. package/dist/types/components/spacer.d.ts +11 -0
  13. package/dist/types/components/tab-bar.d.ts +56 -0
  14. package/dist/types/components/text.d.ts +13 -0
  15. package/dist/types/components/truncated-text.d.ts +10 -0
  16. package/dist/types/editor-component.d.ts +36 -0
  17. package/dist/types/fuzzy.d.ts +15 -0
  18. package/dist/types/index.d.ts +26 -0
  19. package/dist/types/keybindings.d.ts +201 -0
  20. package/dist/types/keys.d.ts +208 -0
  21. package/dist/types/kill-ring.d.ts +27 -0
  22. package/dist/types/metrics.d.ts +85 -0
  23. package/dist/types/stdin-buffer.d.ts +50 -0
  24. package/dist/types/symbols.d.ts +23 -0
  25. package/dist/types/terminal-capabilities.d.ts +75 -0
  26. package/dist/types/terminal.d.ts +88 -0
  27. package/dist/types/ttyid.d.ts +9 -0
  28. package/dist/types/tui.d.ts +197 -0
  29. package/dist/types/utils.d.ts +75 -0
  30. package/package.json +9 -8
  31. package/src/components/editor.ts +58 -13
  32. package/src/components/input.ts +2 -1
  33. package/src/components/select-list.ts +8 -1
  34. package/src/terminal.ts +44 -8
  35. 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.#stdoutErrorHandler = (err: Error) => {
281
- this.#markUnavailable(err, "stdout-error");
282
- };
283
- process.stdout.on("error", this.#stdoutErrorHandler);
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
- if (this.#stdoutErrorHandler) {
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.requestRender(!(isMultiplexerSession() && !useLegacyMultiplexerFullRender()), "resize");
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
- fullRender(true, "terminal width changed");
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