@linxiraos/pi-tui 1.1.13 → 1.1.15

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/src/terminal.ts CHANGED
@@ -427,6 +427,7 @@ export function emergencyTerminalRestore(): void {
427
427
  // buffer homes the cursor (unconditional CursorRestoreState
428
428
  // with no prior save), corrupting the shell handoff on exit.
429
429
  (altScreenActive ? "\x1b[?1049l\x1b[?1l\x1b>\x1b[<u" : "") + // Leave alt; reset main keyboard
430
+ "\x1b[0 q" + // Restore the terminal's configured cursor shape (DECSCUSR)
430
431
  "\x1b[?25h", // Show cursor
431
432
  );
432
433
  altScreenActive = false;
@@ -461,6 +462,20 @@ export type TerminalAppearanceRequestToken = number;
461
462
  * set, 4 permanently reset) when the terminal answered DECRQM.
462
463
  */
463
464
  export type PrivateModeReportHandler = (mode: number, supported: boolean, confirmed?: boolean, status?: number) => void;
465
+
466
+ /**
467
+ * Cursor shapes addressable via DECSCUSR (`CSI <n> SP q`). `"default"` (0) hands the shape back to
468
+ * the terminal's own configuration, which is what teardown restores rather than guessing a shape
469
+ * the user never chose.
470
+ */
471
+ export type CursorShape = "default" | "block" | "underline" | "bar";
472
+
473
+ export const CURSOR_SHAPE_CODES: Record<CursorShape, number> = {
474
+ default: 0,
475
+ block: 2,
476
+ underline: 4,
477
+ bar: 6,
478
+ };
464
479
  export interface Terminal {
465
480
  // Start the terminal with input, resize, and host-disconnect handlers.
466
481
  start(
@@ -532,6 +547,12 @@ export interface Terminal {
532
547
  hideCursor(force?: boolean): void; // Hide the cursor
533
548
  showCursor(force?: boolean): void; // Show the cursor
534
549
 
550
+ // Cursor shape (DECSCUSR). Written whenever it changes, whether or not the
551
+ // hardware cursor is currently visible: reshaping a hidden cursor has no
552
+ // visible effect, and `stop()` restores the user's configured shape. Hosts
553
+ // that render a software cursor simply never call this.
554
+ setCursorShape?(shape: CursorShape): void;
555
+
535
556
  // Clear operations
536
557
  clearLine(): void; // Clear current line
537
558
  clearFromCursor(): void; // Clear from cursor to end of screen
@@ -682,6 +703,9 @@ export class ProcessTerminal implements Terminal {
682
703
  // unknown (fresh start, resize, or an alt-screen switch newer than the
683
704
  // last cursor sequence — some hosts keep DECTCEM per buffer).
684
705
  #cursorVisible: boolean | undefined;
706
+ // Last DECSCUSR shape written, so per-keystroke mode changes dedupe.
707
+ // `undefined` = never set, i.e. the terminal's own configured shape.
708
+ #cursorShape: CursorShape | undefined;
685
709
  // Captured at construction and re-read at start(): when true, every real
686
710
  // terminal side effect (writes, probes, raw mode, SIGWINCH, timers) is
687
711
  // suppressed. Defaults on under `bun test` — see isTerminalHeadless().
@@ -1733,6 +1757,13 @@ export class ProcessTerminal implements Terminal {
1733
1757
  this.#safeWrite("\x1b[?2004l");
1734
1758
  this.#safeWrite("\x1b[?5522l");
1735
1759
 
1760
+ // Hand the cursor shape back to the user's terminal configuration; a Vim
1761
+ // Normal-mode block must not outlive the session in their shell.
1762
+ if (this.#cursorShape !== undefined && this.#cursorShape !== "default") {
1763
+ this.#safeWrite(`\x1b[${CURSOR_SHAPE_CODES.default} q`);
1764
+ }
1765
+ this.#cursorShape = undefined;
1766
+
1736
1767
  // Disable mouse tracking (enabled only by fullscreen overlays; safe
1737
1768
  // no-ops otherwise). Covers crash paths that reach stop() without the
1738
1769
  // TUI's own overlay teardown running.
@@ -2031,6 +2062,17 @@ export class ProcessTerminal implements Terminal {
2031
2062
  this.#safeWrite("\x1b[?25h");
2032
2063
  }
2033
2064
 
2065
+ /**
2066
+ * Set the hardware cursor shape (DECSCUSR). Deduped against the last shape written so a
2067
+ * per-keystroke mode indicator does not add a sequence to every frame; {@link stop} restores
2068
+ * `"default"` so the user's own cursor configuration survives exit.
2069
+ */
2070
+ setCursorShape(shape: CursorShape): void {
2071
+ if (this.#cursorShape === shape) return;
2072
+ this.#cursorShape = shape;
2073
+ this.#safeWrite(`\x1b[${CURSOR_SHAPE_CODES[shape]} q`);
2074
+ }
2075
+
2034
2076
  /**
2035
2077
  * Sniff outgoing data for the last cursor-visibility change so the tracked
2036
2078
  * state stays correct for sequences embedded in frame buffers
package/src/tui.ts CHANGED
@@ -82,13 +82,16 @@ const PAINT_END = `${ENABLE_AUTOWRAP}${SYNC_OUTPUT_END}`;
82
82
  const PAINT_BEGIN_NO_SYNC = `${HIDE_CURSOR}${DISABLE_AUTOWRAP}`;
83
83
  const PAINT_END_NO_SYNC = ENABLE_AUTOWRAP;
84
84
  // Mouse reporting is scoped to fullscreen overlays that opt into pointer
85
- // interaction. 1000h = button click tracking, 1003h = any-motion tracking for
86
- // hover targets, and 1006h = SGR extended coordinates past column/row 223.
87
- // Selection-first overlays leave these modes disabled so the terminal retains
85
+ // interaction, plus the opt-in normal-buffer click capture (`tui.mouse`).
86
+ // 1000h = button click tracking, 1003h = any-motion tracking for hover
87
+ // targets, and 1006h = SGR extended coordinates past column/row 223.
88
+ // Selection-first surfaces leave these modes disabled so the terminal retains
88
89
  // native text selection.
89
90
  const MOUSE_TRACKING_ON = "\x1b[?1000h\x1b[?1003h\x1b[?1006h";
90
91
  const MOUSE_TRACKING_OFF = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
91
92
 
93
+ type MouseTrackingState = "off" | "inline" | "full";
94
+
92
95
  /**
93
96
  * `PI_TUI_RESIZE_IN_PLACE=1|true` forces in-place resize (no alt-buffer borrow).
94
97
  * `0|false` forces the alt-buffer path even on Warp. Unset defers to Warp detection:
@@ -666,6 +669,11 @@ export class TUI extends Container {
666
669
  // Screen row where the provider's mutable viewport begins (0-based); rows
667
670
  // above it hold history still visible on the physical screen.
668
671
  #providerViewportTop = 0;
672
+ // Net composer-space offset of the published hit-test origin behind the
673
+ // painted top, from the last paint: replay-replaced rows minus viewport
674
+ // rows the paint prepended for a short viewport. Negative while prepended
675
+ // blanks outweigh replaced rows; zero on ordinary frames.
676
+ #providerViewportPadTop = 0;
669
677
  // Viewport-relative row of the hardware cursor after the last normal paint
670
678
  // (0 = parked at the viewport top). A resize reflows the normal buffer
671
679
  // before the app hears about it; terminals keep the cursor attached to its
@@ -845,7 +853,9 @@ export class TUI extends Container {
845
853
  // untouched, so exiting reconciles cleanly against the terminal-restored
846
854
  // normal screen. #altPreviousLines is the last alt frame, for repaint-skip.
847
855
  #altActive = false;
848
- #altMouseTrackingActive = false;
856
+ #mouseTracking: MouseTrackingState = "off";
857
+ /** Product-owned probe for opt-in normal-buffer click capture (`tui.mouse`). Read every frame. */
858
+ #inlineMouseProvider: (() => boolean) | undefined;
849
859
  #altPreviousLines: string[] = [];
850
860
  #altEnterWidth = 0;
851
861
  #altEnterHeight = 0;
@@ -1184,6 +1194,54 @@ export class TUI extends Container {
1184
1194
  return this.overlayStack.some(o => this.#isOverlayVisible(o));
1185
1195
  }
1186
1196
 
1197
+ /**
1198
+ * Mutable normal-buffer viewport from the last provider frame: screen row
1199
+ * where it begins plus its row count. Inline click targets are indexed
1200
+ * into this window (`screenRow - top`). Empty while the alt screen owns
1201
+ * the display, while a resize transaction is settling, and while a Ghostty
1202
+ * image paint is deferred — the painted rows predate the latest spans in
1203
+ * all three cases, so hits would map to unrelated old rows.
1204
+ * The origin is in composer rows: a replay paint replaces leading composer
1205
+ * blanks with history rows and prepends blanks for a short viewport, so
1206
+ * the painted top is backed out by that net pad.
1207
+ */
1208
+ getMutableViewport(): { top: number; length: number } {
1209
+ if (
1210
+ this.#altActive ||
1211
+ this.#resizeAltActive ||
1212
+ this.#resizeProbe !== undefined ||
1213
+ this.#resizeInPlaceActive ||
1214
+ this.#ghosttyInitialImageDelayTimer !== undefined
1215
+ ) {
1216
+ return { top: 0, length: 0 };
1217
+ }
1218
+ return { top: this.#providerViewportTop - this.#providerViewportPadTop, length: this.#providerWindow.length };
1219
+ }
1220
+
1221
+ /**
1222
+ * Probe for opt-in normal-buffer click capture. The provider is read every
1223
+ * frame; while it returns true (and no fullscreen overlay owns the
1224
+ * display) the terminal reports button clicks as SGR events for inline
1225
+ * click targets. Native text selection becomes Shift+drag while on.
1226
+ */
1227
+ setInlineMouseTrackingProvider(provider: (() => boolean) | undefined): void {
1228
+ this.#inlineMouseProvider = provider;
1229
+ }
1230
+
1231
+ /** Transition mouse reporting, emitting only the sequences a change needs. */
1232
+ #setMouseTracking(state: MouseTrackingState): void {
1233
+ if (state === this.#mouseTracking) return;
1234
+ const wasOff = this.#mouseTracking === "off";
1235
+ this.#mouseTracking = state;
1236
+ if (state === "off") {
1237
+ if (!wasOff) this.terminal.write(MOUSE_TRACKING_OFF);
1238
+ return;
1239
+ }
1240
+ // Inline and fullscreen reporting are the same bytes: moving between
1241
+ // live modes needs no emission, only entering from off does.
1242
+ if (wasOff) this.terminal.write(MOUSE_TRACKING_ON);
1243
+ }
1244
+
1187
1245
  /** Check if an overlay entry is currently visible */
1188
1246
  #isOverlayVisible(entry: (typeof this.overlayStack)[number]): boolean {
1189
1247
  if (entry.hidden) return false;
@@ -1658,6 +1716,9 @@ export class TUI extends Container {
1658
1716
  fs.appendFileSync(getDebugLogPath(), msg);
1659
1717
  }
1660
1718
  this.#providerViewportTop = Math.min(top, Math.max(0, height - 1));
1719
+ // Resolved geometry invalidates the replay offset with the old anchor;
1720
+ // the forced repaint recomputes it (usually zero).
1721
+ this.#providerViewportPadTop = 0;
1661
1722
  this.#forceViewportRepaintOnNextRender = true;
1662
1723
  this.requestRender(true);
1663
1724
  }
@@ -1904,14 +1965,27 @@ export class TUI extends Container {
1904
1965
  setAltScreenActive(false);
1905
1966
  }
1906
1967
  if (this.#altActive || this.#pendingAltExit) {
1907
- const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : "";
1908
- const exitSequence = this.#pendingAltExit || `${mouseExit}${this.#keyboardEnhancementExit()}\x1b[?1049l`;
1968
+ // A pending fused exit may have been built without an OFF write to
1969
+ // keep inline capture alive across the restore — at process quit
1970
+ // nothing continues, so release unconditionally. The pending
1971
+ // sequence itself can re-enable tracking (overlay-close restore),
1972
+ // so the final OFF goes last or the shell keeps reporting.
1973
+ const mouseExit = this.#mouseTracking !== "off" ? MOUSE_TRACKING_OFF : "";
1974
+ const exitSequence = this.#pendingAltExit
1975
+ ? `${this.#pendingAltExit}${mouseExit}`
1976
+ : `${mouseExit}${this.#keyboardEnhancementExit()}\x1b[?1049l`;
1909
1977
  this.terminal.write(exitSequence);
1910
1978
  setAltScreenActive(false);
1911
1979
  this.#altActive = false;
1912
- this.#altMouseTrackingActive = false;
1980
+ this.#mouseTracking = "off";
1913
1981
  this.#altPreviousLines = [];
1914
1982
  this.#pendingAltExit = "";
1983
+ } else if (this.#mouseTracking !== "off") {
1984
+ // Inline capture with no overlay: still owned by us at quit, so
1985
+ // release it — otherwise the parent shell keeps mouse reporting
1986
+ // and loses native selection until a manual reset.
1987
+ this.terminal.write(MOUSE_TRACKING_OFF);
1988
+ this.#mouseTracking = "off";
1915
1989
  }
1916
1990
  // A latched destructive reset (settled rebuild-mode resize, /clear) pairs
1917
1991
  // ED3 with a complete-ledger replay. Running that pair during stop would
@@ -2692,9 +2766,11 @@ export class TUI extends Container {
2692
2766
 
2693
2767
  let historyRows = history?.rows ?? [];
2694
2768
  let replayViewportRows = 0;
2769
+ let replayPrependedBlanks = 0;
2695
2770
  if (history?.kind === "replay") {
2696
2771
  // Providers may omit unused leading rows from a short viewport. Make
2697
2772
  // that logical space explicit before the bottom-first replay split.
2773
+ replayPrependedBlanks = Math.max(0, height - viewport.length);
2698
2774
  while (viewport.length < height) viewport.unshift("");
2699
2775
  let leadingBlankRows = 0;
2700
2776
  while (leadingBlankRows < viewport.length && !/\S/.test(viewport[leadingBlankRows]!)) {
@@ -2843,6 +2919,7 @@ export class TUI extends Container {
2843
2919
  else this.#recordHardwareCursorHidden();
2844
2920
  this.#providerWindow = mutablePrepared;
2845
2921
  this.#providerViewportTop = mutableTop;
2922
+ this.#providerViewportPadTop = replayViewportRows - replayPrependedBlanks;
2846
2923
  this.#previousWidth = width;
2847
2924
  this.#previousHeight = height;
2848
2925
  this.#resizeBurstGrew = false;
@@ -2890,28 +2967,41 @@ export class TUI extends Container {
2890
2967
  // modal there; the normal screen and all accounting stay untouched.
2891
2968
  const topOverlay = this.#getTopmostVisibleOverlay();
2892
2969
  const wantAlt = topOverlay?.options?.fullscreen === true;
2893
- const wantMouseTracking = wantAlt && topOverlay.options?.mouseTracking !== false;
2970
+ const wantMouse: MouseTrackingState =
2971
+ topOverlay === undefined
2972
+ ? this.#inlineMouseProvider?.() === true
2973
+ ? "inline"
2974
+ : "off"
2975
+ : wantAlt && topOverlay.options?.mouseTracking !== false
2976
+ ? "full"
2977
+ : "off";
2894
2978
  if (wantAlt && !this.#altActive) {
2895
2979
  // Enhanced keyboard modes can be buffer-local: re-push the active
2896
2980
  // modified-key reporting sequence on the freshly entered alternate
2897
2981
  // screen, or Esc/modified keys revert to legacy encoding inside
2898
2982
  // fullscreen overlays (Ghostty/kitty/iTerm2).
2899
2983
  this.#noteAltBufferToggle();
2900
- const mouseEnter = wantMouseTracking ? MOUSE_TRACKING_ON : "";
2901
- this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}${mouseEnter}`);
2984
+ this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}`);
2985
+ this.#setMouseTracking(wantMouse);
2902
2986
  setAltScreenActive(true);
2903
2987
  this.terminal.hideCursor();
2904
2988
  this.#forgetHardwareCursorState();
2905
2989
  this.#recordHardwareCursorHidden();
2906
2990
  this.#altActive = true;
2907
- this.#altMouseTrackingActive = wantMouseTracking;
2908
2991
  this.#altPreviousLines = [];
2909
2992
  this.#altEnterWidth = width;
2910
2993
  this.#altEnterHeight = height;
2911
2994
  } else if (!wantAlt && this.#altActive) {
2912
- const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : "";
2995
+ // Leaving reporting on when the normal buffer wants it restores
2996
+ // inline capture the same frame the overlay closes: no later paint
2997
+ // is needed, so an idle session never sits untrackable.
2998
+ const mouseExit = wantMouse === "off" && this.#mouseTracking !== "off" ? MOUSE_TRACKING_OFF : "";
2999
+ // A fullscreen overlay that disabled reporting leaves tracking off:
3000
+ // restore it in the fused exit or later frames see matching states
3001
+ // and inline click/hover stays dead until the setting toggles.
3002
+ const mouseEnter = wantMouse !== "off" && this.#mouseTracking === "off" ? MOUSE_TRACKING_ON : "";
2913
3003
  const enhancementExit = this.#keyboardEnhancementExit();
2914
- const exitSequence = `${mouseExit}${enhancementExit}\x1b[?1049l`;
3004
+ const exitSequence = `${mouseExit}${mouseEnter}${enhancementExit}\x1b[?1049l`;
2915
3005
  // Session replacement finishes while its fullscreen selector still
2916
3006
  // covers the old normal buffer. Fuse the restore into the destructive
2917
3007
  // repaint so no stale frame can become visible between writes.
@@ -2924,7 +3014,7 @@ export class TUI extends Container {
2924
3014
  }
2925
3015
  this.#forgetHardwareCursorState();
2926
3016
  this.#altActive = false;
2927
- this.#altMouseTrackingActive = false;
3017
+ this.#mouseTracking = wantMouse;
2928
3018
  this.#altPreviousLines = [];
2929
3019
  // The alt-buffer restore put the pre-overlay normal screen back. If
2930
3020
  // that buffer resized while covered, its cursor moved with width
@@ -2938,9 +3028,8 @@ export class TUI extends Container {
2938
3028
  }
2939
3029
  this.#forceViewportRepaintOnNextRender = true;
2940
3030
  }
2941
- } else if (wantMouseTracking !== this.#altMouseTrackingActive) {
2942
- this.terminal.write(wantMouseTracking ? MOUSE_TRACKING_ON : MOUSE_TRACKING_OFF);
2943
- this.#altMouseTrackingActive = wantMouseTracking;
3031
+ } else if (wantMouse !== this.#mouseTracking) {
3032
+ this.#setMouseTracking(wantMouse);
2944
3033
  }
2945
3034
  if (this.#altActive) {
2946
3035
  this.#renderAltFrame(width, height);