@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/CHANGELOG.md +2 -37
- package/dist/types/components/editor.d.ts +30 -0
- package/dist/types/mouse.d.ts +5 -5
- package/dist/types/terminal.d.ts +14 -0
- package/dist/types/tui.d.ts +22 -0
- package/dist/types/vim.d.ts +94 -0
- package/package.json +3 -3
- package/src/components/editor.ts +492 -22
- package/src/mouse.ts +5 -5
- package/src/terminal.ts +42 -0
- package/src/tui.ts +106 -17
- package/src/vim.ts +760 -0
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
|
|
86
|
-
//
|
|
87
|
-
//
|
|
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
|
-
#
|
|
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
|
-
|
|
1908
|
-
|
|
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.#
|
|
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
|
|
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
|
-
|
|
2901
|
-
this
|
|
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
|
-
|
|
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.#
|
|
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 (
|
|
2942
|
-
this
|
|
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);
|