@sayknow-cli/tui 0.3.15 → 0.4.0
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/README.md +18 -1
- package/package.json +8 -9
- package/src/autocomplete.ts +31 -1
- package/src/bracketed-paste.ts +106 -29
- package/src/components/editor.ts +81 -37
- package/src/components/input.ts +5 -1
- package/src/components/secret-input.ts +489 -0
- package/src/components/select-list.ts +138 -33
- package/src/index.ts +1 -0
- package/src/keybindings.ts +11 -3
- package/src/stdin-buffer.ts +153 -20
- package/src/terminal.ts +38 -2
- package/src/tui.ts +210 -26
- package/dist/types/animation-scheduler.d.ts +0 -13
- package/dist/types/autocomplete.d.ts +0 -83
- package/dist/types/bracketed-paste.d.ts +0 -26
- package/dist/types/components/box.d.ts +0 -20
- package/dist/types/components/cancellable-loader.d.ts +0 -21
- package/dist/types/components/editor.d.ts +0 -126
- package/dist/types/components/image.d.ts +0 -18
- package/dist/types/components/input.d.ts +0 -16
- package/dist/types/components/loader.d.ts +0 -23
- package/dist/types/components/markdown.d.ts +0 -87
- package/dist/types/components/sayknow-pet.d.ts +0 -128
- package/dist/types/components/select-list.d.ts +0 -46
- package/dist/types/components/settings-list.d.ts +0 -39
- package/dist/types/components/spacer.d.ts +0 -11
- package/dist/types/components/tab-bar.d.ts +0 -56
- package/dist/types/components/text.d.ts +0 -22
- package/dist/types/components/truncated-text.d.ts +0 -10
- package/dist/types/editor-component.d.ts +0 -36
- package/dist/types/fuzzy.d.ts +0 -15
- package/dist/types/index.d.ts +0 -28
- package/dist/types/keybindings.d.ts +0 -201
- package/dist/types/keys.d.ts +0 -208
- package/dist/types/kill-ring.d.ts +0 -27
- package/dist/types/metrics.d.ts +0 -85
- package/dist/types/stdin-buffer.d.ts +0 -50
- package/dist/types/symbols.d.ts +0 -23
- package/dist/types/terminal-capabilities.d.ts +0 -187
- package/dist/types/terminal.d.ts +0 -90
- package/dist/types/ttyid.d.ts +0 -9
- package/dist/types/tui.d.ts +0 -269
- package/dist/types/utils.d.ts +0 -110
package/src/tui.ts
CHANGED
|
@@ -45,6 +45,44 @@ type InputListener = (data: string) => InputListenerResult;
|
|
|
45
45
|
/**
|
|
46
46
|
* Component interface - all components must implement this
|
|
47
47
|
*/
|
|
48
|
+
export type MouseEvent = {
|
|
49
|
+
kind: "wheel" | "click";
|
|
50
|
+
direction?: -1 | 1;
|
|
51
|
+
button?: 0;
|
|
52
|
+
/** Terminal cell coordinates, one-based. */
|
|
53
|
+
x: number;
|
|
54
|
+
y: number;
|
|
55
|
+
/** Focused-overlay cell coordinates, one-based when dispatched to an overlay. */
|
|
56
|
+
localX?: number;
|
|
57
|
+
localY?: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type OverlayMouseBounds = {
|
|
61
|
+
row: number;
|
|
62
|
+
col: number;
|
|
63
|
+
width: number;
|
|
64
|
+
height: number;
|
|
65
|
+
termWidth: number;
|
|
66
|
+
termHeight: number;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Parse xterm SGR mouse reports. Drag and button-release reports are ignored. */
|
|
70
|
+
export function parseSgrMouseEvent(data: string): MouseEvent | undefined {
|
|
71
|
+
const match = data.match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/);
|
|
72
|
+
if (!match) return undefined;
|
|
73
|
+
const button = Number(match[1]);
|
|
74
|
+
const x = Number(match[2]);
|
|
75
|
+
const y = Number(match[3]);
|
|
76
|
+
const terminator = match[4];
|
|
77
|
+
if (![button, x, y].every(Number.isSafeInteger) || x < 1 || y < 1) return undefined;
|
|
78
|
+
|
|
79
|
+
if (button & 32 || terminator === "m") return undefined;
|
|
80
|
+
if (button === 64) return { kind: "wheel", direction: -1, x, y };
|
|
81
|
+
if (button === 65) return { kind: "wheel", direction: 1, x, y };
|
|
82
|
+
if (button === 0) return { kind: "click", button, x, y };
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
48
86
|
export interface Component {
|
|
49
87
|
/**
|
|
50
88
|
* Render the component to lines for the given viewport width
|
|
@@ -58,6 +96,9 @@ export interface Component {
|
|
|
58
96
|
*/
|
|
59
97
|
handleInput?(data: string): void;
|
|
60
98
|
|
|
99
|
+
/** Optional handler for terminal mouse events when component has focus. */
|
|
100
|
+
handleMouse?(event: MouseEvent): void;
|
|
101
|
+
|
|
61
102
|
/**
|
|
62
103
|
* If true, component receives key release events (Kitty protocol).
|
|
63
104
|
* Default is false - release events are filtered out.
|
|
@@ -279,31 +320,41 @@ function isViewportSensitiveHost(
|
|
|
279
320
|
env: Record<string, string | undefined>,
|
|
280
321
|
platform: NodeJS.Platform,
|
|
281
322
|
includeNativeWindows: boolean,
|
|
323
|
+
includeProcessTerminal: boolean,
|
|
282
324
|
): boolean {
|
|
283
|
-
return
|
|
325
|
+
return (
|
|
326
|
+
isMultiplexerSession(env) ||
|
|
327
|
+
isWindowsTerminalSession(env) ||
|
|
328
|
+
includeProcessTerminal ||
|
|
329
|
+
(includeNativeWindows && platform === "win32")
|
|
330
|
+
);
|
|
284
331
|
}
|
|
285
332
|
/**
|
|
286
333
|
* True when repainting only the live viewport is safer than clearing/replaying
|
|
287
|
-
* the full transcript.
|
|
288
|
-
*
|
|
289
|
-
*
|
|
334
|
+
* the full transcript. Real process terminals are viewport-sensitive because
|
|
335
|
+
* their native scrollback position is not observable by the renderer. Native
|
|
336
|
+
* Windows console hosts are also recognized from platform identity when that
|
|
337
|
+
* process-terminal capability is unavailable.
|
|
290
338
|
*/
|
|
291
339
|
export function shouldUseViewportRepaintForHost(
|
|
292
340
|
env: Record<string, string | undefined> = Bun.env,
|
|
293
341
|
platform: NodeJS.Platform = process.platform,
|
|
294
|
-
options: { includeNativeWindows?: boolean } = {},
|
|
342
|
+
options: { includeNativeWindows?: boolean; includeProcessTerminal?: boolean } = {},
|
|
295
343
|
): boolean {
|
|
296
344
|
const multiplexed = isMultiplexerSession(env);
|
|
297
345
|
const includeNativeWindows = options.includeNativeWindows ?? true;
|
|
346
|
+
const includeProcessTerminal = options.includeProcessTerminal ?? false;
|
|
298
347
|
return (
|
|
299
|
-
isViewportSensitiveHost(env, platform, includeNativeWindows) &&
|
|
348
|
+
isViewportSensitiveHost(env, platform, includeNativeWindows, includeProcessTerminal) &&
|
|
300
349
|
!(multiplexed && useLegacyMultiplexerFullRender(env))
|
|
301
350
|
);
|
|
302
351
|
}
|
|
303
352
|
|
|
304
353
|
function useViewportRepaintPath(terminal: Terminal): boolean {
|
|
354
|
+
if (terminal.isProcessTerminal !== true) return false;
|
|
305
355
|
return shouldUseViewportRepaintForHost(Bun.env, process.platform, {
|
|
306
|
-
includeNativeWindows:
|
|
356
|
+
includeNativeWindows: true,
|
|
357
|
+
includeProcessTerminal: true,
|
|
307
358
|
});
|
|
308
359
|
}
|
|
309
360
|
|
|
@@ -319,7 +370,8 @@ function allowsHostNeutralOverflowRepaint(
|
|
|
319
370
|
}
|
|
320
371
|
|
|
321
372
|
function shouldPreserveScrollbackOnFullClear(terminal: Terminal): boolean {
|
|
322
|
-
|
|
373
|
+
if (terminal.isProcessTerminal !== true) return false;
|
|
374
|
+
return isViewportSensitiveHost(Bun.env, process.platform, true, true);
|
|
323
375
|
}
|
|
324
376
|
|
|
325
377
|
/**
|
|
@@ -618,6 +670,7 @@ export class TUI extends Container {
|
|
|
618
670
|
#stopped = false;
|
|
619
671
|
#terminalUnavailable = false;
|
|
620
672
|
#bottomPinnedComponent: Component | null = null;
|
|
673
|
+
#pendingTerminalCleanup: Array<{ payload: string; onDelivered?: () => void }> = [];
|
|
621
674
|
|
|
622
675
|
#unsubscribeTabWidthChange?: () => void;
|
|
623
676
|
static #renderCounters: TuiRenderCounterSnapshot = {
|
|
@@ -661,9 +714,14 @@ export class TUI extends Container {
|
|
|
661
714
|
options?: OverlayOptions;
|
|
662
715
|
preFocus: Component | null;
|
|
663
716
|
hidden: boolean;
|
|
717
|
+
mouseBounds?: OverlayMouseBounds;
|
|
664
718
|
}[] = [];
|
|
665
719
|
|
|
666
|
-
constructor(
|
|
720
|
+
constructor(
|
|
721
|
+
terminal: Terminal,
|
|
722
|
+
showHardwareCursor?: boolean,
|
|
723
|
+
private readonly options: { enableMouse?: boolean } = {},
|
|
724
|
+
) {
|
|
667
725
|
super();
|
|
668
726
|
this.terminal = terminal;
|
|
669
727
|
if (showHardwareCursor !== undefined) {
|
|
@@ -758,6 +816,50 @@ export class TUI extends Container {
|
|
|
758
816
|
if (this.#manualViewportAnchor !== null) this.#reconcileMissingViewportAnchor = true;
|
|
759
817
|
}
|
|
760
818
|
|
|
819
|
+
/** Reveal a semantic viewport anchor without changing the rendered content width. */
|
|
820
|
+
revealViewportAnchor(id: ViewportAnchorId, alignment: "top" | "center" | "bottom"): boolean {
|
|
821
|
+
const height = this.terminal.rows;
|
|
822
|
+
const width = this.terminal.columns;
|
|
823
|
+
const frame = this.#viewportAnchorFrame;
|
|
824
|
+
if (height <= 0 || width <= 0 || this.#previousLines.length === 0 || frame === null) return false;
|
|
825
|
+
|
|
826
|
+
const selectedRow = frame.anchors.findIndex(anchor => anchor?.id === id);
|
|
827
|
+
const selected = selectedRow < 0 ? null : frame.anchors[selectedRow];
|
|
828
|
+
if (selected === null) return false;
|
|
829
|
+
|
|
830
|
+
const desiredScreenRow = alignment === "top" ? 0 : alignment === "center" ? Math.floor(height / 2) : height - 1;
|
|
831
|
+
const targetViewportTop = Math.max(0, frame.startRow + selectedRow - desiredScreenRow);
|
|
832
|
+
this.#manualViewportAnchor = {
|
|
833
|
+
id: selected.id,
|
|
834
|
+
graphemeIndex: selected.graphemeStart,
|
|
835
|
+
cellOffset: selected.cellStart,
|
|
836
|
+
desiredScreenRow,
|
|
837
|
+
};
|
|
838
|
+
const firstCandidateRow = Math.max(0, targetViewportTop - frame.startRow);
|
|
839
|
+
const lastCandidateRow = Math.min(frame.anchors.length, targetViewportTop + height - frame.startRow);
|
|
840
|
+
const fallbacks: ManualViewportAnchor[] = [];
|
|
841
|
+
for (let row = firstCandidateRow; row < lastCandidateRow; row++) {
|
|
842
|
+
const anchor = frame.anchors[row];
|
|
843
|
+
if (anchor === null || row === selectedRow) continue;
|
|
844
|
+
fallbacks.push({
|
|
845
|
+
id: anchor.id,
|
|
846
|
+
graphemeIndex: anchor.graphemeStart,
|
|
847
|
+
cellOffset: anchor.cellStart,
|
|
848
|
+
desiredScreenRow: row + frame.startRow - targetViewportTop,
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
fallbacks.sort(
|
|
852
|
+
(a, b) =>
|
|
853
|
+
Math.abs(a.desiredScreenRow - this.#manualViewportAnchor!.desiredScreenRow) -
|
|
854
|
+
Math.abs(b.desiredScreenRow - this.#manualViewportAnchor!.desiredScreenRow),
|
|
855
|
+
);
|
|
856
|
+
this.#manualViewportFallbackAnchors = fallbacks;
|
|
857
|
+
this.#manualViewportTop = this.#viewportTopRow;
|
|
858
|
+
this.#reconcileMissingViewportAnchor = false;
|
|
859
|
+
this.requestRender();
|
|
860
|
+
return true;
|
|
861
|
+
}
|
|
862
|
+
|
|
761
863
|
scrollViewportPages(direction: -1 | 1): boolean {
|
|
762
864
|
const height = this.terminal.rows;
|
|
763
865
|
const width = this.terminal.columns;
|
|
@@ -866,7 +968,8 @@ export class TUI extends Container {
|
|
|
866
968
|
* Returns a handle to control the overlay's visibility.
|
|
867
969
|
*/
|
|
868
970
|
showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {
|
|
869
|
-
const entry = { component, options, preFocus: this.#focusedComponent, hidden: false };
|
|
971
|
+
const entry = { component, options, preFocus: this.#focusedComponent, hidden: false, mouseBounds: undefined };
|
|
972
|
+
|
|
870
973
|
this.overlayStack.push(entry);
|
|
871
974
|
// Only focus if overlay is actually visible
|
|
872
975
|
if (this.#isOverlayVisible(entry)) {
|
|
@@ -880,6 +983,8 @@ export class TUI extends Container {
|
|
|
880
983
|
hide: () => {
|
|
881
984
|
const index = this.overlayStack.indexOf(entry);
|
|
882
985
|
if (index !== -1) {
|
|
986
|
+
entry.mouseBounds = undefined;
|
|
987
|
+
|
|
883
988
|
this.overlayStack.splice(index, 1);
|
|
884
989
|
// Restore focus if this overlay had focus
|
|
885
990
|
if (this.#focusedComponent === component) {
|
|
@@ -893,6 +998,8 @@ export class TUI extends Container {
|
|
|
893
998
|
setHidden: (hidden: boolean) => {
|
|
894
999
|
if (entry.hidden === hidden) return;
|
|
895
1000
|
entry.hidden = hidden;
|
|
1001
|
+
entry.mouseBounds = undefined;
|
|
1002
|
+
|
|
896
1003
|
// Update focus when hiding/showing
|
|
897
1004
|
if (hidden) {
|
|
898
1005
|
// If this overlay had focus, move focus to next visible or preFocus
|
|
@@ -916,6 +1023,7 @@ export class TUI extends Container {
|
|
|
916
1023
|
hideOverlay(): void {
|
|
917
1024
|
const overlay = this.overlayStack.pop();
|
|
918
1025
|
if (!overlay) return;
|
|
1026
|
+
overlay.mouseBounds = undefined;
|
|
919
1027
|
// Find topmost visible overlay, or fall back to preFocus
|
|
920
1028
|
const topVisible = this.#getTopmostVisibleOverlay();
|
|
921
1029
|
this.setFocus(topVisible?.component ?? overlay.preFocus);
|
|
@@ -950,11 +1058,13 @@ export class TUI extends Container {
|
|
|
950
1058
|
override invalidate(): void {
|
|
951
1059
|
super.invalidate();
|
|
952
1060
|
for (const overlay of this.overlayStack) overlay.component.invalidate?.();
|
|
1061
|
+
for (const overlay of this.overlayStack) overlay.mouseBounds = undefined;
|
|
953
1062
|
}
|
|
954
1063
|
|
|
955
1064
|
start(): void {
|
|
956
1065
|
this.#stopped = false;
|
|
957
1066
|
this.#terminalUnavailable = false;
|
|
1067
|
+
this.terminal.setMouseEnabled?.(this.options.enableMouse === true);
|
|
958
1068
|
this.terminal.start(
|
|
959
1069
|
data => this.#handleInput(data),
|
|
960
1070
|
() => {
|
|
@@ -962,6 +1072,7 @@ export class TUI extends Container {
|
|
|
962
1072
|
this.requestResizeRender();
|
|
963
1073
|
},
|
|
964
1074
|
);
|
|
1075
|
+
this.flushTerminalCleanup();
|
|
965
1076
|
this.#hideCursor();
|
|
966
1077
|
this.#querySixelSupport();
|
|
967
1078
|
this.#queryCellSize();
|
|
@@ -1171,6 +1282,7 @@ export class TUI extends Container {
|
|
|
1171
1282
|
}
|
|
1172
1283
|
|
|
1173
1284
|
stop(): void {
|
|
1285
|
+
this.flushTerminalCleanup();
|
|
1174
1286
|
this.#clearSixelProbeState();
|
|
1175
1287
|
this.#stopped = true;
|
|
1176
1288
|
if (this.#renderTimer) {
|
|
@@ -1229,7 +1341,7 @@ export class TUI extends Container {
|
|
|
1229
1341
|
* `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy tmux redraw.
|
|
1230
1342
|
*/
|
|
1231
1343
|
requestResizeRender(): void {
|
|
1232
|
-
this.requestRender(!useViewportRepaintPath(this.terminal)
|
|
1344
|
+
this.requestRender(!useViewportRepaintPath(this.terminal), "resize");
|
|
1233
1345
|
}
|
|
1234
1346
|
|
|
1235
1347
|
requestRender(force = false, source = "unknown"): void {
|
|
@@ -1357,6 +1469,44 @@ export class TUI extends Container {
|
|
|
1357
1469
|
data = current;
|
|
1358
1470
|
}
|
|
1359
1471
|
|
|
1472
|
+
const mouse = parseSgrMouseEvent(data);
|
|
1473
|
+
if (mouse) {
|
|
1474
|
+
// Coordinates outside the current terminal cannot name a visible cell.
|
|
1475
|
+
if (mouse.x > this.terminal.columns || mouse.y > this.terminal.rows) return;
|
|
1476
|
+
if (mouse.kind === "wheel") this.scrollViewportPages(mouse.direction!);
|
|
1477
|
+
else {
|
|
1478
|
+
const focusedOverlay = this.overlayStack.find(o => o.component === this.#focusedComponent);
|
|
1479
|
+
if (focusedOverlay) {
|
|
1480
|
+
if (!this.#isOverlayVisible(focusedOverlay)) {
|
|
1481
|
+
focusedOverlay.mouseBounds = undefined;
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
const bounds = focusedOverlay.mouseBounds;
|
|
1485
|
+
if (bounds?.termWidth !== this.terminal.columns || bounds.termHeight !== this.terminal.rows) {
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
if (
|
|
1490
|
+
!bounds ||
|
|
1491
|
+
mouse.x < bounds.col + 1 ||
|
|
1492
|
+
mouse.x > bounds.col + bounds.width ||
|
|
1493
|
+
mouse.y < bounds.row + 1 ||
|
|
1494
|
+
mouse.y > bounds.row + bounds.height
|
|
1495
|
+
)
|
|
1496
|
+
return;
|
|
1497
|
+
this.#focusedComponent?.handleMouse?.({
|
|
1498
|
+
...mouse,
|
|
1499
|
+
localX: mouse.x - bounds.col,
|
|
1500
|
+
localY: mouse.y - bounds.row,
|
|
1501
|
+
});
|
|
1502
|
+
} else this.#focusedComponent?.handleMouse?.(mouse);
|
|
1503
|
+
}
|
|
1504
|
+
this.requestRender(false, "mouse");
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
// SGR-looking reports, including malformed reports, are terminal controls.
|
|
1508
|
+
if (data.startsWith("\x1b[<")) return;
|
|
1509
|
+
|
|
1360
1510
|
// Consume terminal cell size responses without blocking unrelated input.
|
|
1361
1511
|
if (this.#consumeCellSizeResponse(data)) {
|
|
1362
1512
|
return;
|
|
@@ -1556,6 +1706,7 @@ export class TUI extends Container {
|
|
|
1556
1706
|
#compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] {
|
|
1557
1707
|
if (this.overlayStack.length === 0) return lines;
|
|
1558
1708
|
const result = [...lines];
|
|
1709
|
+
for (const entry of this.overlayStack) entry.mouseBounds = undefined;
|
|
1559
1710
|
|
|
1560
1711
|
// Pre-render all visible overlays and calculate positions
|
|
1561
1712
|
const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = [];
|
|
@@ -1583,6 +1734,7 @@ export class TUI extends Container {
|
|
|
1583
1734
|
const { row, col } = this.#resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);
|
|
1584
1735
|
|
|
1585
1736
|
rendered.push({ overlayLines, row, col, w: width });
|
|
1737
|
+
entry.mouseBounds = { row, col, width, height: overlayLines.length, termWidth, termHeight };
|
|
1586
1738
|
minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
|
|
1587
1739
|
}
|
|
1588
1740
|
|
|
@@ -2182,16 +2334,25 @@ export class TUI extends Container {
|
|
|
2182
2334
|
this.#previousHeight = height;
|
|
2183
2335
|
};
|
|
2184
2336
|
|
|
2185
|
-
const viewportRepaint = (reason: string): void => {
|
|
2337
|
+
const viewportRepaint = (reason: string, absoluteClear = false): void => {
|
|
2186
2338
|
this.#fullRedrawCount += 1;
|
|
2187
2339
|
if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
|
|
2188
2340
|
const nextViewportTop = Math.max(0, newLines.length - height);
|
|
2189
|
-
const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
|
|
2190
2341
|
let buffer = "\x1b[?2026h";
|
|
2191
|
-
if (
|
|
2192
|
-
|
|
2342
|
+
if (absoluteClear) {
|
|
2343
|
+
// A width reflow under a terminal multiplexer re-wraps the on-screen rows to
|
|
2344
|
+
// the new width before SIGWINCH fires, so cursor-relative row math (from the
|
|
2345
|
+
// old width) lands on the wrong physical row and corrupts the repaint. Clear
|
|
2346
|
+
// the visible screen and repaint from absolute home; skip 3J so scrollback
|
|
2347
|
+
// history is preserved.
|
|
2348
|
+
buffer += "\x1b[2J\x1b[H";
|
|
2349
|
+
} else {
|
|
2350
|
+
const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
|
|
2351
|
+
if (currentScreenRow > 0) {
|
|
2352
|
+
buffer += `\x1b[${currentScreenRow}A`;
|
|
2353
|
+
}
|
|
2354
|
+
buffer += "\r";
|
|
2193
2355
|
}
|
|
2194
|
-
buffer += "\r";
|
|
2195
2356
|
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
2196
2357
|
if (screenRow > 0) buffer += "\r\n";
|
|
2197
2358
|
buffer += "\x1b[2K";
|
|
@@ -2253,13 +2414,22 @@ export class TUI extends Container {
|
|
|
2253
2414
|
|
|
2254
2415
|
// Width changes always need a full re-render because wrapping changes.
|
|
2255
2416
|
if (widthChanged) {
|
|
2417
|
+
// A forced render (requestRender(true)) resets #previousWidth to -1: that is a
|
|
2418
|
+
// *fake* width change (the terminal never reflowed), so it keeps the cheap
|
|
2419
|
+
// cursor-relative repaint. A *real* width change (a valid prior width that
|
|
2420
|
+
// differs) means the multiplexer already re-wrapped the on-screen rows,
|
|
2421
|
+
// invalidating cursor-relative row math, so it needs the absolute clear.
|
|
2422
|
+
const realWidthChange = this.#previousWidth > 0 && this.#previousWidth !== width;
|
|
2256
2423
|
logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
|
|
2257
2424
|
if (useViewportRepaintPath(this.terminal)) {
|
|
2258
2425
|
// In viewport-repaint sessions a full replay can either pile the transcript
|
|
2259
2426
|
// back onto scrollback (tmux/screen) or visibly jump to the transcript top
|
|
2260
|
-
// (Windows Terminal).
|
|
2261
|
-
//
|
|
2262
|
-
|
|
2427
|
+
// (Windows Terminal). For a real width change the reflow invalidates
|
|
2428
|
+
// cursor-relative math, so clear the visible screen and repaint the viewport
|
|
2429
|
+
// from absolute home (absoluteClear) — fixing the corruption without replaying
|
|
2430
|
+
// the whole transcript (no scrollback storm). A fake (force) width change
|
|
2431
|
+
// keeps the in-place relative repaint.
|
|
2432
|
+
viewportRepaint(`terminal width changed (${this.#previousWidth} -> ${width})`, realWidthChange);
|
|
2263
2433
|
} else {
|
|
2264
2434
|
fullRender(true, "terminal width changed");
|
|
2265
2435
|
}
|
|
@@ -2274,11 +2444,9 @@ export class TUI extends Container {
|
|
|
2274
2444
|
viewportRepaint(`terminal height changed (${this.#previousHeight} -> ${height})`);
|
|
2275
2445
|
return;
|
|
2276
2446
|
}
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
return;
|
|
2281
|
-
}
|
|
2447
|
+
logRedraw(`terminal height changed (${this.#previousHeight} -> ${height})`);
|
|
2448
|
+
fullRender(true, "terminal height changed");
|
|
2449
|
+
return;
|
|
2282
2450
|
}
|
|
2283
2451
|
|
|
2284
2452
|
// Content shrunk below the previous render and no overlays - re-render to clear empty rows
|
|
@@ -2333,8 +2501,8 @@ export class TUI extends Container {
|
|
|
2333
2501
|
}
|
|
2334
2502
|
|
|
2335
2503
|
const nextLiveViewportTop = Math.max(0, newLines.length - height);
|
|
2336
|
-
if (
|
|
2337
|
-
viewportRepaint(`
|
|
2504
|
+
if (newLines.length < this.#previousLines.length && nextLiveViewportTop !== prevViewportTop) {
|
|
2505
|
+
viewportRepaint(`content contraction changed viewport top (${prevViewportTop} -> ${nextLiveViewportTop})`);
|
|
2338
2506
|
return;
|
|
2339
2507
|
}
|
|
2340
2508
|
// All changes are in deleted lines (nothing to render, just clear)
|
|
@@ -2580,6 +2748,22 @@ export class TUI extends Container {
|
|
|
2580
2748
|
return { seq, toRow: targetRow };
|
|
2581
2749
|
}
|
|
2582
2750
|
|
|
2751
|
+
/** Retain terminal cleanup until a write succeeds, even after its component is disposed. */
|
|
2752
|
+
queueTerminalCleanup(payload: string, onDelivered?: () => void): void {
|
|
2753
|
+
this.#pendingTerminalCleanup.push({ payload, onDelivered });
|
|
2754
|
+
this.flushTerminalCleanup();
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
/** Retry queued terminal cleanup after terminal recovery or before shutdown. */
|
|
2758
|
+
flushTerminalCleanup(): void {
|
|
2759
|
+
while (this.#pendingTerminalCleanup.length > 0) {
|
|
2760
|
+
const pending = this.#pendingTerminalCleanup[0];
|
|
2761
|
+
if (!this.#writeTerminal(pending.payload)) return;
|
|
2762
|
+
this.#pendingTerminalCleanup.shift();
|
|
2763
|
+
pending.onDelivered?.();
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2583
2767
|
/**
|
|
2584
2768
|
* Register an emitter whose escape payload is appended to every render
|
|
2585
2769
|
* write (inside its own synchronized-output block, cursor saved/restored).
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
export type AnimationCadence = 16 | 80;
|
|
2
|
-
type AnimationCallback = (now: number) => void;
|
|
3
|
-
export interface AnimationRegistration {
|
|
4
|
-
unregister(): void;
|
|
5
|
-
}
|
|
6
|
-
export declare function registerAnimationCallback(callback: AnimationCallback, cadence?: AnimationCadence): AnimationRegistration;
|
|
7
|
-
export declare const __animationSchedulerTestHooks: {
|
|
8
|
-
getActiveTimerCount(cadence?: AnimationCadence): number;
|
|
9
|
-
getRegistrantCount(cadence?: AnimationCadence): number;
|
|
10
|
-
getStartedTimerCount(cadence?: AnimationCadence): number;
|
|
11
|
-
reset(): void;
|
|
12
|
-
};
|
|
13
|
-
export {};
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
export declare function getSlashCommandMatchRank(query: string, commandName: string): number;
|
|
2
|
-
export declare function extractSlashCommandTokenPrefix(text: string): string | null;
|
|
3
|
-
export interface AutocompleteItem {
|
|
4
|
-
value: string;
|
|
5
|
-
label: string;
|
|
6
|
-
description?: string;
|
|
7
|
-
/** Dim hint text shown inline after cursor when this item is selected */
|
|
8
|
-
hint?: string;
|
|
9
|
-
}
|
|
10
|
-
type Awaitable<T> = T | Promise<T>;
|
|
11
|
-
export interface SlashCommand {
|
|
12
|
-
name: string;
|
|
13
|
-
description?: string;
|
|
14
|
-
argumentHint?: string;
|
|
15
|
-
/**
|
|
16
|
-
* Higher values surface first in autocomplete, ahead of fuzzy-score ordering.
|
|
17
|
-
* Use this to pin first-class commands (e.g. bundled SKC skills) to the top.
|
|
18
|
-
*/
|
|
19
|
-
priority?: number;
|
|
20
|
-
getArgumentCompletions?(argumentPrefix: string): Awaitable<AutocompleteItem[] | null>;
|
|
21
|
-
/** Return inline hint text for the current argument state (shown as dim ghost text after cursor) */
|
|
22
|
-
getInlineHint?(argumentText: string): string | null;
|
|
23
|
-
}
|
|
24
|
-
export interface AutocompleteProvider {
|
|
25
|
-
/** Get autocomplete suggestions for current text/cursor position */
|
|
26
|
-
getSuggestions(lines: string[], cursorLine: number, cursorCol: number): Promise<{
|
|
27
|
-
items: AutocompleteItem[];
|
|
28
|
-
prefix: string;
|
|
29
|
-
} | null>;
|
|
30
|
-
/** Apply the selected item and return new text + cursor position */
|
|
31
|
-
applyCompletion(lines: string[], cursorLine: number, cursorCol: number, item: AutocompleteItem, prefix: string): {
|
|
32
|
-
lines: string[];
|
|
33
|
-
cursorLine: number;
|
|
34
|
-
cursorCol: number;
|
|
35
|
-
onApplied?: () => void;
|
|
36
|
-
};
|
|
37
|
-
/** Get inline hint text to show as dim ghost text after the cursor */
|
|
38
|
-
getInlineHint?(lines: string[], cursorLine: number, cursorCol: number): string | null;
|
|
39
|
-
/** Synchronously try to complete a slash command at the start of a line (no async I/O). */
|
|
40
|
-
/** Returns matched items and the full prefix, or null if not applicable. */
|
|
41
|
-
trySyncSlashCompletion?(textBeforeCursor: string): {
|
|
42
|
-
items: AutocompleteItem[];
|
|
43
|
-
prefix: string;
|
|
44
|
-
} | null;
|
|
45
|
-
/**
|
|
46
|
-
* Synchronously try to expand text immediately before the cursor (no async I/O).
|
|
47
|
-
* Called after every single-character insert. Implementations MUST cheaply
|
|
48
|
-
* early-return when the trailing context cannot trigger them.
|
|
49
|
-
* Returns the number of characters to delete immediately before the cursor
|
|
50
|
-
* and the literal string to insert in their place, or null to leave the
|
|
51
|
-
* buffer untouched.
|
|
52
|
-
*/
|
|
53
|
-
trySyncInlineReplace?(textBeforeCursor: string): {
|
|
54
|
-
replaceLen: number;
|
|
55
|
-
insert: string;
|
|
56
|
-
} | null;
|
|
57
|
-
}
|
|
58
|
-
export declare class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
59
|
-
#private;
|
|
60
|
-
constructor(commands?: (SlashCommand | AutocompleteItem)[], basePath?: string);
|
|
61
|
-
getSuggestions(lines: string[], cursorLine: number, cursorCol: number): Promise<{
|
|
62
|
-
items: AutocompleteItem[];
|
|
63
|
-
prefix: string;
|
|
64
|
-
} | null>;
|
|
65
|
-
applyCompletion(lines: string[], cursorLine: number, cursorCol: number, item: AutocompleteItem, prefix: string): {
|
|
66
|
-
lines: string[];
|
|
67
|
-
cursorLine: number;
|
|
68
|
-
cursorCol: number;
|
|
69
|
-
};
|
|
70
|
-
invalidateDirCache(dir?: string): void;
|
|
71
|
-
getForceFileSuggestions(lines: string[], cursorLine: number, cursorCol: number): Promise<{
|
|
72
|
-
items: AutocompleteItem[];
|
|
73
|
-
prefix: string;
|
|
74
|
-
} | null>;
|
|
75
|
-
shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean;
|
|
76
|
-
/** Get inline hint text for slash commands with subcommand hints */
|
|
77
|
-
getInlineHint(lines: string[], cursorLine: number, cursorCol: number): string | null;
|
|
78
|
-
trySyncSlashCompletion(textBeforeCursor: string): {
|
|
79
|
-
items: AutocompleteItem[];
|
|
80
|
-
prefix: string;
|
|
81
|
-
} | null;
|
|
82
|
-
}
|
|
83
|
-
export {};
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
export type PasteResult = {
|
|
2
|
-
handled: false;
|
|
3
|
-
} | {
|
|
4
|
-
handled: true;
|
|
5
|
-
pasteContent?: string;
|
|
6
|
-
remaining: string;
|
|
7
|
-
};
|
|
8
|
-
/**
|
|
9
|
-
* Handles bracketed paste mode buffering for terminal input components.
|
|
10
|
-
*
|
|
11
|
-
* Bracketed paste mode wraps pasted content between start (\x1b[200~) and
|
|
12
|
-
* end (\x1b[201~) markers, which may arrive split across multiple chunks.
|
|
13
|
-
* This class buffers incoming data and assembles complete paste payloads.
|
|
14
|
-
*/
|
|
15
|
-
export declare class BracketedPasteHandler {
|
|
16
|
-
#private;
|
|
17
|
-
/**
|
|
18
|
-
* Process incoming terminal data for bracketed paste sequences.
|
|
19
|
-
*
|
|
20
|
-
* @returns `{ handled: false }` if the data contains no paste sequence and
|
|
21
|
-
* should be processed normally. `{ handled: true }` if the data was
|
|
22
|
-
* consumed by paste buffering — `pasteContent` is set when a complete
|
|
23
|
-
* paste has been assembled; omitted when still buffering.
|
|
24
|
-
*/
|
|
25
|
-
process(data: string): PasteResult;
|
|
26
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import type { Component } from "../tui";
|
|
2
|
-
/**
|
|
3
|
-
* Box component - a container that applies padding and background to all children
|
|
4
|
-
*/
|
|
5
|
-
export declare class Box implements Component {
|
|
6
|
-
#private;
|
|
7
|
-
children: Component[];
|
|
8
|
-
constructor(paddingX?: number, paddingY?: number, bgFn?: (text: string) => string);
|
|
9
|
-
addChild(component: Component): void;
|
|
10
|
-
removeChild(component: Component): void;
|
|
11
|
-
/** Remove a child without disposing it (for detach-then-readd reuse). */
|
|
12
|
-
detachChild(component: Component): void;
|
|
13
|
-
clear(): void;
|
|
14
|
-
/** Remove all children without disposing them (for detach-then-readd reuse). */
|
|
15
|
-
detachAll(): void;
|
|
16
|
-
dispose(): void;
|
|
17
|
-
setBgFn(bgFn?: (text: string) => string): void;
|
|
18
|
-
invalidate(): void;
|
|
19
|
-
render(width: number): string[];
|
|
20
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { Loader } from "./loader";
|
|
2
|
-
/**
|
|
3
|
-
* Loader that can be cancelled with Escape.
|
|
4
|
-
* Extends Loader with an AbortSignal for cancelling async operations.
|
|
5
|
-
*
|
|
6
|
-
* @example
|
|
7
|
-
* const loader = new CancellableLoader(tui, cyan, dim, "Working...");
|
|
8
|
-
* loader.onAbort = () => done(null);
|
|
9
|
-
* doWork(loader.signal).then(done);
|
|
10
|
-
*/
|
|
11
|
-
export declare class CancellableLoader extends Loader {
|
|
12
|
-
#private;
|
|
13
|
-
/** Called when user presses Escape */
|
|
14
|
-
onAbort?: () => void;
|
|
15
|
-
/** AbortSignal that is aborted when user presses Escape */
|
|
16
|
-
get signal(): AbortSignal;
|
|
17
|
-
/** Whether the loader was aborted */
|
|
18
|
-
get aborted(): boolean;
|
|
19
|
-
handleInput(data: string): void;
|
|
20
|
-
dispose(): void;
|
|
21
|
-
}
|