@oh-my-pi/pi-tui 18.1.17 → 18.1.19

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.
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * SGR mouse report parsing (`\x1b[<button;col;rowM` / `…m`).
3
3
  *
4
- * Mouse tracking is enabled only while a fullscreen overlay holds the
5
- * alternate screen (see tui.ts MOUSE_TRACKING_ON), so consumers are
6
- * fullscreen components hit-testing against their own rendered frame:
7
- * the frame paints from screen row 0, hence `row`/`col` are exposed
8
- * 0-based for direct indexing into rendered lines.
4
+ * Mouse tracking is enabled while a fullscreen overlay holds the alternate
5
+ * screen (see tui.ts MOUSE_TRACKING_ON), or opt-in via `tui.mouse` — on the
6
+ * normal buffer whenever no overlay is visible. Consumers hit-test
7
+ * against their own rendered frame: the frame paints from screen row 0, hence
8
+ * `row`/`col` are exposed 0-based for direct indexing into rendered lines.
9
9
  */
10
10
  /** A decoded SGR mouse report. */
11
11
  export interface SgrMouseEvent {
@@ -334,6 +334,28 @@ export declare class TUI extends Container {
334
334
  hideOverlay(): void;
335
335
  /** Check if there are any visible overlays */
336
336
  hasOverlay(): boolean;
337
+ /**
338
+ * Mutable normal-buffer viewport from the last provider frame: screen row
339
+ * where it begins plus its row count. Inline click targets are indexed
340
+ * into this window (`screenRow - top`). Empty while the alt screen owns
341
+ * the display, while a resize transaction is settling, and while a Ghostty
342
+ * image paint is deferred — the painted rows predate the latest spans in
343
+ * all three cases, so hits would map to unrelated old rows.
344
+ * The origin is in composer rows: a replay paint replaces leading composer
345
+ * blanks with history rows and prepends blanks for a short viewport, so
346
+ * the painted top is backed out by that net pad.
347
+ */
348
+ getMutableViewport(): {
349
+ top: number;
350
+ length: number;
351
+ };
352
+ /**
353
+ * Probe for opt-in normal-buffer click capture. The provider is read every
354
+ * frame; while it returns true (and no fullscreen overlay owns the
355
+ * display) the terminal reports button clicks as SGR events for inline
356
+ * click targets. Native text selection becomes Shift+drag while on.
357
+ */
358
+ setInlineMouseTrackingProvider(provider: (() => boolean) | undefined): void;
337
359
  invalidate(): void;
338
360
  start(options?: TUIStartOptions): void;
339
361
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.1.17",
4
+ "version": "18.1.19",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -37,8 +37,8 @@
37
37
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "18.1.17",
41
- "@oh-my-pi/pi-utils": "18.1.17"
40
+ "@oh-my-pi/pi-natives": "18.1.19",
41
+ "@oh-my-pi/pi-utils": "18.1.19"
42
42
  },
43
43
  "devDependencies": {
44
44
  "kitty-vt-wasm": "^0.2.0"
package/src/mouse.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * SGR mouse report parsing (`\x1b[<button;col;rowM` / `…m`).
3
3
  *
4
- * Mouse tracking is enabled only while a fullscreen overlay holds the
5
- * alternate screen (see tui.ts MOUSE_TRACKING_ON), so consumers are
6
- * fullscreen components hit-testing against their own rendered frame:
7
- * the frame paints from screen row 0, hence `row`/`col` are exposed
8
- * 0-based for direct indexing into rendered lines.
4
+ * Mouse tracking is enabled while a fullscreen overlay holds the alternate
5
+ * screen (see tui.ts MOUSE_TRACKING_ON), or opt-in via `tui.mouse` — on the
6
+ * normal buffer whenever no overlay is visible. Consumers hit-test
7
+ * against their own rendered frame: the frame paints from screen row 0, hence
8
+ * `row`/`col` are exposed 0-based for direct indexing into rendered lines.
9
9
  */
10
10
 
11
11
  /** A decoded SGR mouse report. */
package/src/tui.ts CHANGED
@@ -81,13 +81,16 @@ const PAINT_END = `${ENABLE_AUTOWRAP}${SYNC_OUTPUT_END}`;
81
81
  const PAINT_BEGIN_NO_SYNC = `${HIDE_CURSOR}${DISABLE_AUTOWRAP}`;
82
82
  const PAINT_END_NO_SYNC = ENABLE_AUTOWRAP;
83
83
  // Mouse reporting is scoped to fullscreen overlays that opt into pointer
84
- // interaction. 1000h = button click tracking, 1003h = any-motion tracking for
85
- // hover targets, and 1006h = SGR extended coordinates past column/row 223.
86
- // Selection-first overlays leave these modes disabled so the terminal retains
84
+ // interaction, plus the opt-in normal-buffer click capture (`tui.mouse`).
85
+ // 1000h = button click tracking, 1003h = any-motion tracking for hover
86
+ // targets, and 1006h = SGR extended coordinates past column/row 223.
87
+ // Selection-first surfaces leave these modes disabled so the terminal retains
87
88
  // native text selection.
88
89
  const MOUSE_TRACKING_ON = "\x1b[?1000h\x1b[?1003h\x1b[?1006h";
89
90
  const MOUSE_TRACKING_OFF = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
90
91
 
92
+ type MouseTrackingState = "off" | "inline" | "full";
93
+
91
94
  /**
92
95
  * `PI_TUI_RESIZE_IN_PLACE=1|true` forces in-place resize (no alt-buffer borrow).
93
96
  * `0|false` forces the alt-buffer path even on Warp. Unset defers to Warp detection:
@@ -665,6 +668,11 @@ export class TUI extends Container {
665
668
  // Screen row where the provider's mutable viewport begins (0-based); rows
666
669
  // above it hold history still visible on the physical screen.
667
670
  #providerViewportTop = 0;
671
+ // Net composer-space offset of the published hit-test origin behind the
672
+ // painted top, from the last paint: replay-replaced rows minus viewport
673
+ // rows the paint prepended for a short viewport. Negative while prepended
674
+ // blanks outweigh replaced rows; zero on ordinary frames.
675
+ #providerViewportPadTop = 0;
668
676
  // Viewport-relative row of the hardware cursor after the last normal paint
669
677
  // (0 = parked at the viewport top). A resize reflows the normal buffer
670
678
  // before the app hears about it; terminals keep the cursor attached to its
@@ -827,7 +835,9 @@ export class TUI extends Container {
827
835
  // untouched, so exiting reconciles cleanly against the terminal-restored
828
836
  // normal screen. #altPreviousLines is the last alt frame, for repaint-skip.
829
837
  #altActive = false;
830
- #altMouseTrackingActive = false;
838
+ #mouseTracking: MouseTrackingState = "off";
839
+ /** Product-owned probe for opt-in normal-buffer click capture (`tui.mouse`). Read every frame. */
840
+ #inlineMouseProvider: (() => boolean) | undefined;
831
841
  #altPreviousLines: string[] = [];
832
842
  #altEnterWidth = 0;
833
843
  #altEnterHeight = 0;
@@ -1088,6 +1098,54 @@ export class TUI extends Container {
1088
1098
  return this.overlayStack.some(o => this.#isOverlayVisible(o));
1089
1099
  }
1090
1100
 
1101
+ /**
1102
+ * Mutable normal-buffer viewport from the last provider frame: screen row
1103
+ * where it begins plus its row count. Inline click targets are indexed
1104
+ * into this window (`screenRow - top`). Empty while the alt screen owns
1105
+ * the display, while a resize transaction is settling, and while a Ghostty
1106
+ * image paint is deferred — the painted rows predate the latest spans in
1107
+ * all three cases, so hits would map to unrelated old rows.
1108
+ * The origin is in composer rows: a replay paint replaces leading composer
1109
+ * blanks with history rows and prepends blanks for a short viewport, so
1110
+ * the painted top is backed out by that net pad.
1111
+ */
1112
+ getMutableViewport(): { top: number; length: number } {
1113
+ if (
1114
+ this.#altActive ||
1115
+ this.#resizeAltActive ||
1116
+ this.#resizeProbe !== undefined ||
1117
+ this.#resizeInPlaceActive ||
1118
+ this.#ghosttyInitialImageDelayTimer !== undefined
1119
+ ) {
1120
+ return { top: 0, length: 0 };
1121
+ }
1122
+ return { top: this.#providerViewportTop - this.#providerViewportPadTop, length: this.#providerWindow.length };
1123
+ }
1124
+
1125
+ /**
1126
+ * Probe for opt-in normal-buffer click capture. The provider is read every
1127
+ * frame; while it returns true (and no fullscreen overlay owns the
1128
+ * display) the terminal reports button clicks as SGR events for inline
1129
+ * click targets. Native text selection becomes Shift+drag while on.
1130
+ */
1131
+ setInlineMouseTrackingProvider(provider: (() => boolean) | undefined): void {
1132
+ this.#inlineMouseProvider = provider;
1133
+ }
1134
+
1135
+ /** Transition mouse reporting, emitting only the sequences a change needs. */
1136
+ #setMouseTracking(state: MouseTrackingState): void {
1137
+ if (state === this.#mouseTracking) return;
1138
+ const wasOff = this.#mouseTracking === "off";
1139
+ this.#mouseTracking = state;
1140
+ if (state === "off") {
1141
+ if (!wasOff) this.terminal.write(MOUSE_TRACKING_OFF);
1142
+ return;
1143
+ }
1144
+ // Inline and fullscreen reporting are the same bytes: moving between
1145
+ // live modes needs no emission, only entering from off does.
1146
+ if (wasOff) this.terminal.write(MOUSE_TRACKING_ON);
1147
+ }
1148
+
1091
1149
  /** Check if an overlay entry is currently visible */
1092
1150
  #isOverlayVisible(entry: (typeof this.overlayStack)[number]): boolean {
1093
1151
  if (entry.hidden) return false;
@@ -1562,6 +1620,9 @@ export class TUI extends Container {
1562
1620
  fs.appendFileSync(getDebugLogPath(), msg);
1563
1621
  }
1564
1622
  this.#providerViewportTop = Math.min(top, Math.max(0, height - 1));
1623
+ // Resolved geometry invalidates the replay offset with the old anchor;
1624
+ // the forced repaint recomputes it (usually zero).
1625
+ this.#providerViewportPadTop = 0;
1565
1626
  this.#forceViewportRepaintOnNextRender = true;
1566
1627
  this.requestRender(true);
1567
1628
  }
@@ -1808,14 +1869,27 @@ export class TUI extends Container {
1808
1869
  setAltScreenActive(false);
1809
1870
  }
1810
1871
  if (this.#altActive || this.#pendingAltExit) {
1811
- const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : "";
1812
- const exitSequence = this.#pendingAltExit || `${mouseExit}${this.#keyboardEnhancementExit()}\x1b[?1049l`;
1872
+ // A pending fused exit may have been built without an OFF write to
1873
+ // keep inline capture alive across the restore — at process quit
1874
+ // nothing continues, so release unconditionally. The pending
1875
+ // sequence itself can re-enable tracking (overlay-close restore),
1876
+ // so the final OFF goes last or the shell keeps reporting.
1877
+ const mouseExit = this.#mouseTracking !== "off" ? MOUSE_TRACKING_OFF : "";
1878
+ const exitSequence = this.#pendingAltExit
1879
+ ? `${this.#pendingAltExit}${mouseExit}`
1880
+ : `${mouseExit}${this.#keyboardEnhancementExit()}\x1b[?1049l`;
1813
1881
  this.terminal.write(exitSequence);
1814
1882
  setAltScreenActive(false);
1815
1883
  this.#altActive = false;
1816
- this.#altMouseTrackingActive = false;
1884
+ this.#mouseTracking = "off";
1817
1885
  this.#altPreviousLines = [];
1818
1886
  this.#pendingAltExit = "";
1887
+ } else if (this.#mouseTracking !== "off") {
1888
+ // Inline capture with no overlay: still owned by us at quit, so
1889
+ // release it — otherwise the parent shell keeps mouse reporting
1890
+ // and loses native selection until a manual reset.
1891
+ this.terminal.write(MOUSE_TRACKING_OFF);
1892
+ this.#mouseTracking = "off";
1819
1893
  }
1820
1894
  // A latched destructive reset (settled rebuild-mode resize, /clear) pairs
1821
1895
  // ED3 with a complete-ledger replay. Running that pair during stop would
@@ -2567,9 +2641,11 @@ export class TUI extends Container {
2567
2641
 
2568
2642
  let historyRows = history?.rows ?? [];
2569
2643
  let replayViewportRows = 0;
2644
+ let replayPrependedBlanks = 0;
2570
2645
  if (history?.kind === "replay") {
2571
2646
  // Providers may omit unused leading rows from a short viewport. Make
2572
2647
  // that logical space explicit before the bottom-first replay split.
2648
+ replayPrependedBlanks = Math.max(0, height - viewport.length);
2573
2649
  while (viewport.length < height) viewport.unshift("");
2574
2650
  let leadingBlankRows = 0;
2575
2651
  while (leadingBlankRows < viewport.length && !/\S/.test(viewport[leadingBlankRows]!)) {
@@ -2718,6 +2794,7 @@ export class TUI extends Container {
2718
2794
  else this.#recordHardwareCursorHidden();
2719
2795
  this.#providerWindow = mutablePrepared;
2720
2796
  this.#providerViewportTop = mutableTop;
2797
+ this.#providerViewportPadTop = replayViewportRows - replayPrependedBlanks;
2721
2798
  this.#previousWidth = width;
2722
2799
  this.#previousHeight = height;
2723
2800
  this.#resizeBurstGrew = false;
@@ -2765,28 +2842,41 @@ export class TUI extends Container {
2765
2842
  // modal there; the normal screen and all accounting stay untouched.
2766
2843
  const topOverlay = this.#getTopmostVisibleOverlay();
2767
2844
  const wantAlt = topOverlay?.options?.fullscreen === true;
2768
- const wantMouseTracking = wantAlt && topOverlay.options?.mouseTracking !== false;
2845
+ const wantMouse: MouseTrackingState =
2846
+ topOverlay === undefined
2847
+ ? this.#inlineMouseProvider?.() === true
2848
+ ? "inline"
2849
+ : "off"
2850
+ : wantAlt && topOverlay.options?.mouseTracking !== false
2851
+ ? "full"
2852
+ : "off";
2769
2853
  if (wantAlt && !this.#altActive) {
2770
2854
  // Enhanced keyboard modes can be buffer-local: re-push the active
2771
2855
  // modified-key reporting sequence on the freshly entered alternate
2772
2856
  // screen, or Esc/modified keys revert to legacy encoding inside
2773
2857
  // fullscreen overlays (Ghostty/kitty/iTerm2).
2774
2858
  this.#noteAltBufferToggle();
2775
- const mouseEnter = wantMouseTracking ? MOUSE_TRACKING_ON : "";
2776
- this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}${mouseEnter}`);
2859
+ this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}`);
2860
+ this.#setMouseTracking(wantMouse);
2777
2861
  setAltScreenActive(true);
2778
2862
  this.terminal.hideCursor();
2779
2863
  this.#forgetHardwareCursorState();
2780
2864
  this.#recordHardwareCursorHidden();
2781
2865
  this.#altActive = true;
2782
- this.#altMouseTrackingActive = wantMouseTracking;
2783
2866
  this.#altPreviousLines = [];
2784
2867
  this.#altEnterWidth = width;
2785
2868
  this.#altEnterHeight = height;
2786
2869
  } else if (!wantAlt && this.#altActive) {
2787
- const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : "";
2870
+ // Leaving reporting on when the normal buffer wants it restores
2871
+ // inline capture the same frame the overlay closes: no later paint
2872
+ // is needed, so an idle session never sits untrackable.
2873
+ const mouseExit = wantMouse === "off" && this.#mouseTracking !== "off" ? MOUSE_TRACKING_OFF : "";
2874
+ // A fullscreen overlay that disabled reporting leaves tracking off:
2875
+ // restore it in the fused exit or later frames see matching states
2876
+ // and inline click/hover stays dead until the setting toggles.
2877
+ const mouseEnter = wantMouse !== "off" && this.#mouseTracking === "off" ? MOUSE_TRACKING_ON : "";
2788
2878
  const enhancementExit = this.#keyboardEnhancementExit();
2789
- const exitSequence = `${mouseExit}${enhancementExit}\x1b[?1049l`;
2879
+ const exitSequence = `${mouseExit}${mouseEnter}${enhancementExit}\x1b[?1049l`;
2790
2880
  // Session replacement finishes while its fullscreen selector still
2791
2881
  // covers the old normal buffer. Fuse the restore into the destructive
2792
2882
  // repaint so no stale frame can become visible between writes.
@@ -2799,7 +2889,7 @@ export class TUI extends Container {
2799
2889
  }
2800
2890
  this.#forgetHardwareCursorState();
2801
2891
  this.#altActive = false;
2802
- this.#altMouseTrackingActive = false;
2892
+ this.#mouseTracking = wantMouse;
2803
2893
  this.#altPreviousLines = [];
2804
2894
  // The alt-buffer restore put the pre-overlay normal screen back. If
2805
2895
  // that buffer resized while covered, its cursor moved with width
@@ -2813,9 +2903,8 @@ export class TUI extends Container {
2813
2903
  }
2814
2904
  this.#forceViewportRepaintOnNextRender = true;
2815
2905
  }
2816
- } else if (wantMouseTracking !== this.#altMouseTrackingActive) {
2817
- this.terminal.write(wantMouseTracking ? MOUSE_TRACKING_ON : MOUSE_TRACKING_OFF);
2818
- this.#altMouseTrackingActive = wantMouseTracking;
2906
+ } else if (wantMouse !== this.#mouseTracking) {
2907
+ this.#setMouseTracking(wantMouse);
2819
2908
  }
2820
2909
  if (this.#altActive) {
2821
2910
  this.#renderAltFrame(width, height);