@gajae-code/tui 0.11.10 → 0.11.11
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 +1 -1
- package/dist/types/components/select-list.d.ts +1 -0
- package/dist/types/tui.d.ts +3 -2
- package/package.json +3 -3
- package/src/components/select-list.ts +16 -0
- package/src/terminal.ts +10 -5
- package/src/tui.ts +163 -10
package/CHANGELOG.md
CHANGED
|
@@ -45,6 +45,7 @@ export declare class SelectList implements Component {
|
|
|
45
45
|
constructor(items: ReadonlyArray<SelectItem>, maxVisible: number, theme: SelectListTheme, layout?: SelectListLayoutOptions);
|
|
46
46
|
setFilter(filter: string): void;
|
|
47
47
|
setSelectedIndex(index: number): void;
|
|
48
|
+
handleNavigation(action: "tui.select.up" | "tui.select.down" | "tui.select.pageUp" | "tui.select.pageDown"): void;
|
|
48
49
|
invalidate(): void;
|
|
49
50
|
render(width: number): string[];
|
|
50
51
|
handleInput(keyData: string): void;
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ type InputListener = (data: string) => InputListenerResult;
|
|
|
9
9
|
* Component interface - all components must implement this
|
|
10
10
|
*/
|
|
11
11
|
export type MouseEvent = {
|
|
12
|
-
kind: "wheel" | "click";
|
|
12
|
+
kind: "wheel" | "click" | "drag" | "release";
|
|
13
13
|
direction?: -1 | 1;
|
|
14
14
|
button?: 0;
|
|
15
15
|
/** Terminal cell coordinates, one-based. */
|
|
@@ -27,7 +27,7 @@ type OverlayMouseBounds = {
|
|
|
27
27
|
termWidth: number;
|
|
28
28
|
termHeight: number;
|
|
29
29
|
};
|
|
30
|
-
/** Parse xterm SGR mouse reports
|
|
30
|
+
/** Parse xterm SGR mouse reports for wheel, left-click, drag, and release events. */
|
|
31
31
|
export declare function parseSgrMouseEvent(data: string): MouseEvent | undefined;
|
|
32
32
|
export interface Component {
|
|
33
33
|
/**
|
|
@@ -230,6 +230,7 @@ export declare class TUI extends Container {
|
|
|
230
230
|
}[];
|
|
231
231
|
constructor(terminal: Terminal, showHardwareCursor?: boolean, options?: {
|
|
232
232
|
enableMouse?: boolean;
|
|
233
|
+
copySelection?: (text: string) => void | Promise<void>;
|
|
233
234
|
});
|
|
234
235
|
dispose(): void;
|
|
235
236
|
get fullRedraws(): number;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/tui",
|
|
4
|
-
"version": "0.11.
|
|
4
|
+
"version": "0.11.11",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://gajae-code.com",
|
|
7
7
|
"author": "Yeachan-Heo and Gajae Code Contributors",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"fmt": "biome format --write ."
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@gajae-code/natives": "0.11.
|
|
40
|
-
"@gajae-code/utils": "0.11.
|
|
39
|
+
"@gajae-code/natives": "0.11.11",
|
|
40
|
+
"@gajae-code/utils": "0.11.11",
|
|
41
41
|
"lru-cache": "11.3.6",
|
|
42
42
|
"marked": "18.0.6"
|
|
43
43
|
},
|
|
@@ -92,6 +92,22 @@ export class SelectList implements Component {
|
|
|
92
92
|
this.#findEnabledIndex(clamped, 1, false) ?? this.#findEnabledIndex(clamped, -1, false) ?? -1;
|
|
93
93
|
this.#syncViewportToIndex(this.#selectedIndex >= 0 ? this.#selectedIndex : clamped);
|
|
94
94
|
}
|
|
95
|
+
handleNavigation(action: "tui.select.up" | "tui.select.down" | "tui.select.pageUp" | "tui.select.pageDown"): void {
|
|
96
|
+
switch (action) {
|
|
97
|
+
case "tui.select.up":
|
|
98
|
+
this.#moveSelection(-1);
|
|
99
|
+
break;
|
|
100
|
+
case "tui.select.down":
|
|
101
|
+
this.#moveSelection(1);
|
|
102
|
+
break;
|
|
103
|
+
case "tui.select.pageUp":
|
|
104
|
+
this.#movePage(-1);
|
|
105
|
+
break;
|
|
106
|
+
case "tui.select.pageDown":
|
|
107
|
+
this.#movePage(1);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
95
111
|
|
|
96
112
|
invalidate(): void {
|
|
97
113
|
// No cached state to invalidate currently
|
package/src/terminal.ts
CHANGED
|
@@ -3,7 +3,6 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import { $env, $flag, $pickenv } from "@gajae-code/utils";
|
|
4
4
|
import { setKittyProtocolActive } from "./keys";
|
|
5
5
|
import { StdinBuffer } from "./stdin-buffer";
|
|
6
|
-
import { isUnderTerminalMultiplexer } from "./terminal-capabilities";
|
|
7
6
|
|
|
8
7
|
const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
|
|
9
8
|
const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
|
|
@@ -52,6 +51,7 @@ export function emergencyTerminalRestore(): void {
|
|
|
52
51
|
process.stdout.write(
|
|
53
52
|
"\x1b[?2004l" + // Disable bracketed paste
|
|
54
53
|
"\x1b[?1000l" + // Disable normal mouse reporting
|
|
54
|
+
"\x1b[?1002l" + // Disable button-event mouse reporting
|
|
55
55
|
"\x1b[?1006l" + // Disable SGR extended mouse reporting
|
|
56
56
|
"\x1b[?2031l" + // Disable Mode 2031 appearance notifications
|
|
57
57
|
"\x1b[<u" + // Pop kitty keyboard protocol
|
|
@@ -244,8 +244,11 @@ export class ProcessTerminal implements Terminal {
|
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
setMouseEnabled(enabled: boolean): void {
|
|
247
|
-
this.#mouseEnabled = enabled
|
|
248
|
-
if (this.#started)
|
|
247
|
+
this.#mouseEnabled = enabled;
|
|
248
|
+
if (this.#started)
|
|
249
|
+
this.#safeWrite(
|
|
250
|
+
this.#mouseEnabled ? "\x1b[?1000l\x1b[?1002h\x1b[?1006h" : "\x1b[?1000l\x1b[?1002l\x1b[?1006l",
|
|
251
|
+
);
|
|
249
252
|
}
|
|
250
253
|
|
|
251
254
|
start(onInput: (data: string) => void, onResize: () => void): void {
|
|
@@ -271,8 +274,9 @@ export class ProcessTerminal implements Terminal {
|
|
|
271
274
|
|
|
272
275
|
// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
|
|
273
276
|
this.#safeWrite("\x1b[?2004h");
|
|
274
|
-
//
|
|
275
|
-
|
|
277
|
+
// Button-event reporting preserves wheel input while also letting the TUI implement drag selection.
|
|
278
|
+
// Clear both tracking variants first so stale modes from another application cannot leak across startup.
|
|
279
|
+
this.#safeWrite(this.#mouseEnabled ? "\x1b[?1000l\x1b[?1002h\x1b[?1006h" : "\x1b[?1000l\x1b[?1002l\x1b[?1006l");
|
|
276
280
|
|
|
277
281
|
// Set up resize handler immediately
|
|
278
282
|
process.stdout.on("resize", this.#resizeHandler);
|
|
@@ -708,6 +712,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
708
712
|
this.#mouseEnabled = false;
|
|
709
713
|
this.#safeWrite("\x1b[?2004l");
|
|
710
714
|
this.#safeWrite("\x1b[?1000l");
|
|
715
|
+
this.#safeWrite("\x1b[?1002l");
|
|
711
716
|
this.#safeWrite("\x1b[?1006l");
|
|
712
717
|
|
|
713
718
|
// Disable Mode 2031 appearance change notifications
|
package/src/tui.ts
CHANGED
|
@@ -38,6 +38,15 @@ const SEGMENT_RESET = "\x1b[0m";
|
|
|
38
38
|
* diffing so `#previousLines` mirrors what was actually written.
|
|
39
39
|
*/
|
|
40
40
|
const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
|
|
41
|
+
const MOUSE_SELECTION_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
42
|
+
|
|
43
|
+
function stripTerminalControls(text: string): string {
|
|
44
|
+
return Bun.stripANSI(text)
|
|
45
|
+
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/gu, "")
|
|
46
|
+
.replace(/\x1b[P_^X][\s\S]*?\x1b\\/gu, "")
|
|
47
|
+
.replace(/\x1b(?:\[[0-?]*[ -/]*[@-~]|[@-_])/gu, "")
|
|
48
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/gu, "");
|
|
49
|
+
}
|
|
41
50
|
|
|
42
51
|
type InputListenerResult = { consume?: boolean; data?: string } | undefined;
|
|
43
52
|
type InputListener = (data: string) => InputListenerResult;
|
|
@@ -46,7 +55,7 @@ type InputListener = (data: string) => InputListenerResult;
|
|
|
46
55
|
* Component interface - all components must implement this
|
|
47
56
|
*/
|
|
48
57
|
export type MouseEvent = {
|
|
49
|
-
kind: "wheel" | "click";
|
|
58
|
+
kind: "wheel" | "click" | "drag" | "release";
|
|
50
59
|
direction?: -1 | 1;
|
|
51
60
|
button?: 0;
|
|
52
61
|
/** Terminal cell coordinates, one-based. */
|
|
@@ -66,7 +75,12 @@ type OverlayMouseBounds = {
|
|
|
66
75
|
termHeight: number;
|
|
67
76
|
};
|
|
68
77
|
|
|
69
|
-
|
|
78
|
+
type MouseSelectionPoint = {
|
|
79
|
+
line: number;
|
|
80
|
+
column: number;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Parse xterm SGR mouse reports for wheel, left-click, drag, and release events. */
|
|
70
84
|
export function parseSgrMouseEvent(data: string): MouseEvent | undefined {
|
|
71
85
|
const match = data.match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/);
|
|
72
86
|
if (!match) return undefined;
|
|
@@ -76,11 +90,17 @@ export function parseSgrMouseEvent(data: string): MouseEvent | undefined {
|
|
|
76
90
|
const terminator = match[4];
|
|
77
91
|
if (![button, x, y].every(Number.isSafeInteger) || x < 1 || y < 1) return undefined;
|
|
78
92
|
|
|
79
|
-
|
|
80
|
-
if (button
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
93
|
+
const baseButton = button & 3;
|
|
94
|
+
if (button & 64) {
|
|
95
|
+
if (terminator !== "M") return undefined;
|
|
96
|
+
if (baseButton === 0) return { kind: "wheel", direction: -1, x, y };
|
|
97
|
+
if (baseButton === 1) return { kind: "wheel", direction: 1, x, y };
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
if (baseButton !== 0) return undefined;
|
|
101
|
+
if (terminator === "m") return { kind: "release", button: 0, x, y };
|
|
102
|
+
if (button & 32) return { kind: "drag", button: 0, x, y };
|
|
103
|
+
return { kind: "click", button: 0, x, y };
|
|
84
104
|
}
|
|
85
105
|
|
|
86
106
|
export interface Component {
|
|
@@ -671,6 +691,9 @@ export class TUI extends Container {
|
|
|
671
691
|
#terminalUnavailable = false;
|
|
672
692
|
#bottomPinnedComponent: Component | null = null;
|
|
673
693
|
#pendingTerminalCleanup: Array<{ payload: string; onDelivered?: () => void }> = [];
|
|
694
|
+
#mouseSelectionStart: MouseSelectionPoint | null = null;
|
|
695
|
+
#mouseSelectionEnd: MouseSelectionPoint | null = null;
|
|
696
|
+
#mouseSelectionDragged = false;
|
|
674
697
|
|
|
675
698
|
#unsubscribeTabWidthChange?: () => void;
|
|
676
699
|
static #renderCounters: TuiRenderCounterSnapshot = {
|
|
@@ -720,7 +743,10 @@ export class TUI extends Container {
|
|
|
720
743
|
constructor(
|
|
721
744
|
terminal: Terminal,
|
|
722
745
|
showHardwareCursor?: boolean,
|
|
723
|
-
private readonly options: {
|
|
746
|
+
private readonly options: {
|
|
747
|
+
enableMouse?: boolean;
|
|
748
|
+
copySelection?: (text: string) => void | Promise<void>;
|
|
749
|
+
} = {},
|
|
724
750
|
) {
|
|
725
751
|
super();
|
|
726
752
|
this.terminal = terminal;
|
|
@@ -1491,8 +1517,11 @@ export class TUI extends Container {
|
|
|
1491
1517
|
if (mouse) {
|
|
1492
1518
|
// Coordinates outside the current terminal cannot name a visible cell.
|
|
1493
1519
|
if (mouse.x > this.terminal.columns || mouse.y > this.terminal.rows) return;
|
|
1494
|
-
if (mouse.kind === "wheel")
|
|
1495
|
-
|
|
1520
|
+
if (mouse.kind === "wheel") {
|
|
1521
|
+
this.#clearMouseSelection();
|
|
1522
|
+
this.scrollViewportPages(mouse.direction!);
|
|
1523
|
+
} else if (mouse.kind === "click") {
|
|
1524
|
+
this.#beginMouseSelection(mouse);
|
|
1496
1525
|
const focusedOverlay = this.overlayStack.find(o => o.component === this.#focusedComponent);
|
|
1497
1526
|
if (focusedOverlay) {
|
|
1498
1527
|
if (!this.#isOverlayVisible(focusedOverlay)) {
|
|
@@ -1518,6 +1547,10 @@ export class TUI extends Container {
|
|
|
1518
1547
|
localY: mouse.y - bounds.row,
|
|
1519
1548
|
});
|
|
1520
1549
|
} else this.#focusedComponent?.handleMouse?.(mouse);
|
|
1550
|
+
} else if (mouse.kind === "drag") {
|
|
1551
|
+
this.#updateMouseSelection(mouse);
|
|
1552
|
+
} else {
|
|
1553
|
+
this.#finishMouseSelection(mouse);
|
|
1521
1554
|
}
|
|
1522
1555
|
this.requestRender(false, "mouse");
|
|
1523
1556
|
return;
|
|
@@ -1562,6 +1595,124 @@ export class TUI extends Container {
|
|
|
1562
1595
|
}
|
|
1563
1596
|
}
|
|
1564
1597
|
|
|
1598
|
+
#mouseSelectionPoint(mouse: MouseEvent): MouseSelectionPoint {
|
|
1599
|
+
return {
|
|
1600
|
+
line: this.#viewportTopRow + mouse.y - 1,
|
|
1601
|
+
column: mouse.x - 1,
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
#beginMouseSelection(mouse: MouseEvent): void {
|
|
1606
|
+
if (!this.options.copySelection) return;
|
|
1607
|
+
const point = this.#mouseSelectionPoint(mouse);
|
|
1608
|
+
this.#mouseSelectionStart = point;
|
|
1609
|
+
this.#mouseSelectionEnd = point;
|
|
1610
|
+
this.#mouseSelectionDragged = false;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
#updateMouseSelection(mouse: MouseEvent): void {
|
|
1614
|
+
if (this.#mouseSelectionStart === null) return;
|
|
1615
|
+
this.#mouseSelectionEnd = this.#mouseSelectionPoint(mouse);
|
|
1616
|
+
this.#mouseSelectionDragged = true;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
#finishMouseSelection(mouse: MouseEvent): void {
|
|
1620
|
+
if (this.#mouseSelectionStart === null) return;
|
|
1621
|
+
this.#mouseSelectionEnd = this.#mouseSelectionPoint(mouse);
|
|
1622
|
+
if (!this.#mouseSelectionDragged || !this.options.copySelection) {
|
|
1623
|
+
this.#clearMouseSelection();
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
const text = this.#extractMouseSelection();
|
|
1627
|
+
if (!text) {
|
|
1628
|
+
this.#clearMouseSelection();
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
try {
|
|
1632
|
+
const result = this.options.copySelection(text);
|
|
1633
|
+
if (result) void result.catch(() => {});
|
|
1634
|
+
} catch {
|
|
1635
|
+
// Clipboard failures are reported by the host callback and must not break terminal input.
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
#clearMouseSelection(): void {
|
|
1640
|
+
this.#mouseSelectionStart = null;
|
|
1641
|
+
this.#mouseSelectionEnd = null;
|
|
1642
|
+
this.#mouseSelectionDragged = false;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
#orderedMouseSelection(): { start: MouseSelectionPoint; end: MouseSelectionPoint } | null {
|
|
1646
|
+
const start = this.#mouseSelectionStart;
|
|
1647
|
+
const end = this.#mouseSelectionEnd;
|
|
1648
|
+
if (start === null || end === null) return null;
|
|
1649
|
+
if (start.line < end.line || (start.line === end.line && start.column <= end.column)) return { start, end };
|
|
1650
|
+
return { start: end, end: start };
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
#mouseSelectionColumns(line: number, text: string): { start: number; end: number } | null {
|
|
1654
|
+
const selection = this.#orderedMouseSelection();
|
|
1655
|
+
if (selection === null || line < selection.start.line || line > selection.end.line) return null;
|
|
1656
|
+
const lineWidth = visibleWidth(text);
|
|
1657
|
+
let start = line === selection.start.line ? selection.start.column : 0;
|
|
1658
|
+
let end = line === selection.end.line ? selection.end.column + 1 : lineWidth;
|
|
1659
|
+
start = Math.max(0, Math.min(lineWidth, start));
|
|
1660
|
+
end = Math.max(0, Math.min(lineWidth, end));
|
|
1661
|
+
|
|
1662
|
+
let column = 0;
|
|
1663
|
+
for (const part of MOUSE_SELECTION_SEGMENTER.segment(text)) {
|
|
1664
|
+
const next = column + Math.max(1, visibleWidth(part.segment));
|
|
1665
|
+
if (column < start && start < next) start = column;
|
|
1666
|
+
if (column < end && end < next) end = next;
|
|
1667
|
+
column = next;
|
|
1668
|
+
}
|
|
1669
|
+
return { start, end };
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
#extractMouseSelection(): string {
|
|
1673
|
+
const selection = this.#orderedMouseSelection();
|
|
1674
|
+
if (selection === null) return "";
|
|
1675
|
+
const selected: string[] = [];
|
|
1676
|
+
for (let lineIndex = selection.start.line; lineIndex <= selection.end.line; lineIndex++) {
|
|
1677
|
+
const line = this.#previousLines[lineIndex];
|
|
1678
|
+
if (line === undefined || TERMINAL.isImageLine(line)) {
|
|
1679
|
+
selected.push("");
|
|
1680
|
+
continue;
|
|
1681
|
+
}
|
|
1682
|
+
const plain = stripTerminalControls(line);
|
|
1683
|
+
const columns = this.#mouseSelectionColumns(lineIndex, plain);
|
|
1684
|
+
if (columns === null || columns.end <= columns.start) {
|
|
1685
|
+
selected.push("");
|
|
1686
|
+
continue;
|
|
1687
|
+
}
|
|
1688
|
+
selected.push(sliceByColumn(plain, columns.start, columns.end - columns.start, false));
|
|
1689
|
+
}
|
|
1690
|
+
return selected.join("\n");
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
#applyMouseSelection(lines: string[]): string[] {
|
|
1694
|
+
if (!this.#mouseSelectionDragged) return lines;
|
|
1695
|
+
const selection = this.#orderedMouseSelection();
|
|
1696
|
+
if (selection === null) return lines;
|
|
1697
|
+
const highlighted = lines;
|
|
1698
|
+
for (let lineIndex = selection.start.line; lineIndex <= selection.end.line; lineIndex++) {
|
|
1699
|
+
const line = highlighted[lineIndex];
|
|
1700
|
+
if (line === undefined || TERMINAL.isImageLine(line)) continue;
|
|
1701
|
+
const plain = stripTerminalControls(line);
|
|
1702
|
+
const width = visibleWidth(plain);
|
|
1703
|
+
const columns = this.#mouseSelectionColumns(lineIndex, plain);
|
|
1704
|
+
if (columns === null || columns.end <= columns.start) continue;
|
|
1705
|
+
const before = sliceByColumn(line, 0, columns.start, false);
|
|
1706
|
+
const selected = sliceByColumn(line, columns.start, columns.end - columns.start, false).replace(
|
|
1707
|
+
/\x1b\[[0-9;]*m/gu,
|
|
1708
|
+
control => `${control}\x1b[7m`,
|
|
1709
|
+
);
|
|
1710
|
+
const after = sliceByColumn(line, columns.end, Math.max(0, width - columns.end), false);
|
|
1711
|
+
highlighted[lineIndex] = `${before}\x1b[7m${selected}\x1b[27m${after}`;
|
|
1712
|
+
}
|
|
1713
|
+
return highlighted;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1565
1716
|
#consumeCellSizeResponse(data: string): boolean {
|
|
1566
1717
|
// Response format: ESC [ 6 ; height ; width t
|
|
1567
1718
|
const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
|
|
@@ -2180,6 +2331,8 @@ export class TUI extends Container {
|
|
|
2180
2331
|
const cursorPos = this.#extractCursorPosition(newLines, height);
|
|
2181
2332
|
this.#lastCursorPosition = cursorPos;
|
|
2182
2333
|
|
|
2334
|
+
newLines = this.#applyMouseSelection(newLines);
|
|
2335
|
+
|
|
2183
2336
|
// Terminate every non-image line so #previousLines mirrors emitted bytes
|
|
2184
2337
|
// (closes SGR + OSC 8 hyperlink state). Must run after cursor extraction
|
|
2185
2338
|
// because the marker is embedded mid-line, and before any diff/full render
|