@gajae-code/tui 0.11.1 → 0.11.2
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/dist/types/stdin-buffer.d.ts +2 -0
- package/dist/types/terminal.d.ts +2 -0
- package/dist/types/tui.d.ts +30 -1
- package/package.json +8 -6
- package/src/components/settings-list.ts +9 -3
- package/src/stdin-buffer.ts +112 -16
- package/src/terminal.ts +16 -0
- package/src/tui.ts +140 -2
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* MIT License - Copyright (c) 2025 opentui
|
|
18
18
|
*/
|
|
19
19
|
import { EventEmitter } from "events";
|
|
20
|
+
/** True for complete SGR mouse CSI reports. These remain control input, never text. */
|
|
21
|
+
export declare function isSgrMouseSequence(sequence: string): boolean;
|
|
20
22
|
export type StdinBufferOptions = {
|
|
21
23
|
/**
|
|
22
24
|
* Maximum time to wait for sequence completion (default: 10ms)
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ export type TerminalAppearance = "dark" | "light";
|
|
|
21
21
|
export interface Terminal {
|
|
22
22
|
start(onInput: (data: string) => void, onResize: () => void): void;
|
|
23
23
|
stop(): void;
|
|
24
|
+
setMouseEnabled?(enabled: boolean): void;
|
|
24
25
|
/**
|
|
25
26
|
* Drain stdin before exiting to prevent Kitty key release events from
|
|
26
27
|
* leaking to the parent shell over slow SSH connections.
|
|
@@ -69,6 +70,7 @@ export declare class ProcessTerminal implements Terminal {
|
|
|
69
70
|
get kittyProtocolActive(): boolean;
|
|
70
71
|
get appearance(): TerminalAppearance | undefined;
|
|
71
72
|
onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
|
|
73
|
+
setMouseEnabled(enabled: boolean): void;
|
|
72
74
|
start(onInput: (data: string) => void, onResize: () => void): void;
|
|
73
75
|
drainInput(maxMs?: number, idleMs?: number): Promise<void>;
|
|
74
76
|
stop(): void;
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -8,6 +8,27 @@ type InputListener = (data: string) => InputListenerResult;
|
|
|
8
8
|
/**
|
|
9
9
|
* Component interface - all components must implement this
|
|
10
10
|
*/
|
|
11
|
+
export type MouseEvent = {
|
|
12
|
+
kind: "wheel" | "click";
|
|
13
|
+
direction?: -1 | 1;
|
|
14
|
+
button?: 0;
|
|
15
|
+
/** Terminal cell coordinates, one-based. */
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
/** Focused-overlay cell coordinates, one-based when dispatched to an overlay. */
|
|
19
|
+
localX?: number;
|
|
20
|
+
localY?: number;
|
|
21
|
+
};
|
|
22
|
+
type OverlayMouseBounds = {
|
|
23
|
+
row: number;
|
|
24
|
+
col: number;
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
termWidth: number;
|
|
28
|
+
termHeight: number;
|
|
29
|
+
};
|
|
30
|
+
/** Parse xterm SGR mouse reports. Drag and button-release reports are ignored. */
|
|
31
|
+
export declare function parseSgrMouseEvent(data: string): MouseEvent | undefined;
|
|
11
32
|
export interface Component {
|
|
12
33
|
/**
|
|
13
34
|
* Render the component to lines for the given viewport width
|
|
@@ -19,6 +40,8 @@ export interface Component {
|
|
|
19
40
|
* Optional handler for keyboard input when component has focus
|
|
20
41
|
*/
|
|
21
42
|
handleInput?(data: string): void;
|
|
43
|
+
/** Optional handler for terminal mouse events when component has focus. */
|
|
44
|
+
handleMouse?(event: MouseEvent): void;
|
|
22
45
|
/**
|
|
23
46
|
* If true, component receives key release events (Kitty protocol).
|
|
24
47
|
* Default is false - release events are filtered out.
|
|
@@ -192,6 +215,7 @@ type TuiRenderCounterSnapshot = {
|
|
|
192
215
|
*/
|
|
193
216
|
export declare class TUI extends Container {
|
|
194
217
|
#private;
|
|
218
|
+
private readonly options;
|
|
195
219
|
terminal: Terminal;
|
|
196
220
|
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
|
197
221
|
onDebug?: () => void;
|
|
@@ -202,8 +226,11 @@ export declare class TUI extends Container {
|
|
|
202
226
|
options?: OverlayOptions;
|
|
203
227
|
preFocus: Component | null;
|
|
204
228
|
hidden: boolean;
|
|
229
|
+
mouseBounds?: OverlayMouseBounds;
|
|
205
230
|
}[];
|
|
206
|
-
constructor(terminal: Terminal, showHardwareCursor?: boolean
|
|
231
|
+
constructor(terminal: Terminal, showHardwareCursor?: boolean, options?: {
|
|
232
|
+
enableMouse?: boolean;
|
|
233
|
+
});
|
|
207
234
|
dispose(): void;
|
|
208
235
|
get fullRedraws(): number;
|
|
209
236
|
getShowHardwareCursor(): boolean;
|
|
@@ -223,6 +250,8 @@ export declare class TUI extends Container {
|
|
|
223
250
|
resetViewportAnchorIntent(): void;
|
|
224
251
|
/** Allow one semantic-neighbor reconciliation after a definitive same-transcript rebuild. */
|
|
225
252
|
prepareViewportAnchorForTranscriptRebuild(): void;
|
|
253
|
+
/** Reveal a semantic viewport anchor without changing the rendered content width. */
|
|
254
|
+
revealViewportAnchor(id: ViewportAnchorId, alignment: "top" | "center" | "bottom"): boolean;
|
|
226
255
|
scrollViewportPages(direction: -1 | 1): boolean;
|
|
227
256
|
followLiveViewport(): boolean;
|
|
228
257
|
/**
|
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.2",
|
|
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",
|
|
@@ -30,19 +30,21 @@
|
|
|
30
30
|
"check:types": "tsc -p tsconfig.json --noEmit",
|
|
31
31
|
"lint": "biome lint .",
|
|
32
32
|
"test": "bun test test/*.test.ts",
|
|
33
|
-
"test:perf": "
|
|
33
|
+
"test:perf": "bun test test/perf-gates.test.ts test/transcript-selection-perf.test.ts",
|
|
34
|
+
"test:pty": "PI_TUI_PTY_TESTS=1 bun test test/pty/*.test.ts",
|
|
34
35
|
"fix": "biome check --write --unsafe .",
|
|
35
36
|
"fmt": "biome format --write ."
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
38
|
-
"@gajae-code/natives": "0.11.
|
|
39
|
-
"@gajae-code/utils": "0.11.
|
|
39
|
+
"@gajae-code/natives": "0.11.2",
|
|
40
|
+
"@gajae-code/utils": "0.11.2",
|
|
40
41
|
"lru-cache": "11.3.6",
|
|
41
|
-
"marked": "
|
|
42
|
+
"marked": "18.0.6"
|
|
42
43
|
},
|
|
43
44
|
"devDependencies": {
|
|
44
45
|
"chalk": "^5.6.2",
|
|
45
|
-
"@xterm/headless": "^6.0.0"
|
|
46
|
+
"@xterm/headless": "^6.0.0",
|
|
47
|
+
"node-pty": "^1.0.0"
|
|
46
48
|
},
|
|
47
49
|
"engines": {
|
|
48
50
|
"bun": ">=1.3.14"
|
|
@@ -120,7 +120,11 @@ export class SettingsList implements Component {
|
|
|
120
120
|
const endIndex = Math.min(startIndex + this.#maxVisible, this.#items.length);
|
|
121
121
|
|
|
122
122
|
// Calculate max label width for alignment
|
|
123
|
-
const maxLabelWidth = Math.min(
|
|
123
|
+
const maxLabelWidth = Math.min(
|
|
124
|
+
30,
|
|
125
|
+
Math.max(0, width - 12),
|
|
126
|
+
Math.max(...this.#items.map(item => visibleWidth(item.label))),
|
|
127
|
+
);
|
|
124
128
|
|
|
125
129
|
// Render visible items
|
|
126
130
|
for (let i = startIndex; i < endIndex; i++) {
|
|
@@ -132,13 +136,15 @@ export class SettingsList implements Component {
|
|
|
132
136
|
const prefixWidth = visibleWidth(prefix);
|
|
133
137
|
|
|
134
138
|
// Pad label to align values
|
|
135
|
-
const labelPadded =
|
|
139
|
+
const labelPadded =
|
|
140
|
+
truncateToWidth(item.label, maxLabelWidth, Ellipsis.Omit) +
|
|
141
|
+
padding(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
|
|
136
142
|
const labelText = this.#theme.label(labelPadded, isSelected);
|
|
137
143
|
|
|
138
144
|
// Calculate space for value
|
|
139
145
|
const separator = " ";
|
|
140
146
|
const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);
|
|
141
|
-
const valueMaxWidth = width - usedWidth - 2;
|
|
147
|
+
const valueMaxWidth = Math.max(0, width - usedWidth - 2);
|
|
142
148
|
|
|
143
149
|
const valueText = this.#theme.value(
|
|
144
150
|
truncateToWidth(item.currentValue, valueMaxWidth, Ellipsis.Omit),
|
package/src/stdin-buffer.ts
CHANGED
|
@@ -23,6 +23,18 @@ import { EventEmitter } from "events";
|
|
|
23
23
|
const ESC = "\x1b";
|
|
24
24
|
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
25
25
|
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
26
|
+
const SGR_QUARANTINE_MAX_BYTES = 256;
|
|
27
|
+
const SGR_QUARANTINE_TIMEOUT_MS = 100;
|
|
28
|
+
|
|
29
|
+
/** True for complete SGR mouse CSI reports. These remain control input, never text. */
|
|
30
|
+
export function isSgrMouseSequence(sequence: string): boolean {
|
|
31
|
+
return /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(sequence);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** True when a buffered sequence begins an SGR mouse report, valid or not. */
|
|
35
|
+
function isSgrMousePrefix(sequence: string): boolean {
|
|
36
|
+
return sequence.startsWith(`${ESC}[<`);
|
|
37
|
+
}
|
|
26
38
|
|
|
27
39
|
function isUtf8LeadByte(byte: number): boolean {
|
|
28
40
|
return byte >= 0xc2 && byte <= 0xf4;
|
|
@@ -137,19 +149,9 @@ function isCompleteCsiSequence(data: string): "complete" | "incomplete" {
|
|
|
137
149
|
// Format: ESC[<B;X;Ym or ESC[<B;X;YM
|
|
138
150
|
if (payload.startsWith("<")) {
|
|
139
151
|
// Must have format: <digits;digits;digits[Mm]
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
// If it ends with M or m but doesn't match the pattern, still incomplete
|
|
145
|
-
if (lastChar === "M" || lastChar === "m") {
|
|
146
|
-
// Check if we have the right structure
|
|
147
|
-
const parts = payload.slice(1, -1).split(";");
|
|
148
|
-
if (parts.length === 3 && parts.every(p => /^\d+$/.test(p))) {
|
|
149
|
-
return "complete";
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
152
|
+
// SGR-looking reports remain terminal control input even when malformed.
|
|
153
|
+
// Treat their final byte as complete so trailing user input is preserved.
|
|
154
|
+
if (lastChar === "M" || lastChar === "m") return "complete";
|
|
153
155
|
return "incomplete";
|
|
154
156
|
}
|
|
155
157
|
|
|
@@ -303,6 +305,10 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
303
305
|
#decoder = new StringDecoder("utf8");
|
|
304
306
|
#decoderHasPendingUtf8 = false;
|
|
305
307
|
#pendingSingleUtf8LeadByte: number | undefined;
|
|
308
|
+
#sgrQuarantine = false;
|
|
309
|
+
#sgrQuarantineBytes = 0;
|
|
310
|
+
#sgrQuarantineSemicolons = 0;
|
|
311
|
+
#sgrQuarantineHasDigit = false;
|
|
306
312
|
|
|
307
313
|
constructor(options: StdinBufferOptions = {}) {
|
|
308
314
|
super();
|
|
@@ -310,8 +316,8 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
310
316
|
}
|
|
311
317
|
|
|
312
318
|
process(data: string | Buffer): void {
|
|
313
|
-
//
|
|
314
|
-
if (this.#timeout) {
|
|
319
|
+
// Do not cancel a bounded SGR quarantine while waiting for its final byte.
|
|
320
|
+
if (this.#timeout && !this.#sgrQuarantine) {
|
|
315
321
|
clearTimeout(this.#timeout);
|
|
316
322
|
this.#timeout = undefined;
|
|
317
323
|
}
|
|
@@ -380,6 +386,11 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
380
386
|
return;
|
|
381
387
|
}
|
|
382
388
|
|
|
389
|
+
if (this.#sgrQuarantine) {
|
|
390
|
+
str = this.#consumeSgrQuarantine(str);
|
|
391
|
+
if (str.length === 0) return;
|
|
392
|
+
}
|
|
393
|
+
|
|
383
394
|
this.#buffer += str;
|
|
384
395
|
|
|
385
396
|
if (this.#pasteMode) {
|
|
@@ -445,13 +456,17 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
445
456
|
this.#buffer = result.remainder;
|
|
446
457
|
|
|
447
458
|
for (const sequence of result.sequences) {
|
|
459
|
+
if (isSgrMousePrefix(sequence) && !isSgrMouseSequence(sequence)) continue;
|
|
448
460
|
this.#emitDataSequence(sequence);
|
|
449
461
|
}
|
|
450
462
|
|
|
451
463
|
if (this.#buffer.length > 0) {
|
|
452
464
|
this.#timeout = setTimeout(() => {
|
|
465
|
+
if (isSgrMousePrefix(this.#buffer)) {
|
|
466
|
+
this.#beginSgrQuarantine();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
453
469
|
const flushed = this.flush();
|
|
454
|
-
|
|
455
470
|
for (const sequence of flushed) {
|
|
456
471
|
this.#emitDataSequence(sequence);
|
|
457
472
|
}
|
|
@@ -459,6 +474,76 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
459
474
|
}
|
|
460
475
|
}
|
|
461
476
|
|
|
477
|
+
#beginSgrQuarantine(): void {
|
|
478
|
+
const suffix = this.#buffer.slice(3);
|
|
479
|
+
let semicolons = 0;
|
|
480
|
+
let hasDigit = false;
|
|
481
|
+
for (let index = 0; index < suffix.length; index += 1) {
|
|
482
|
+
const char = suffix[index]!;
|
|
483
|
+
if (/\d/u.test(char)) {
|
|
484
|
+
hasDigit = true;
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (char === ";" && hasDigit && semicolons < 2) {
|
|
488
|
+
semicolons += 1;
|
|
489
|
+
hasDigit = false;
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
const remainder = suffix.slice(index);
|
|
493
|
+
this.#buffer = "";
|
|
494
|
+
this.#pendingKittyPrintableCodepoint = undefined;
|
|
495
|
+
if (remainder) this.process(remainder);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
this.#buffer = "";
|
|
499
|
+
this.#pendingKittyPrintableCodepoint = undefined;
|
|
500
|
+
this.#sgrQuarantine = true;
|
|
501
|
+
this.#sgrQuarantineBytes = suffix.length;
|
|
502
|
+
this.#sgrQuarantineSemicolons = semicolons;
|
|
503
|
+
this.#sgrQuarantineHasDigit = hasDigit;
|
|
504
|
+
this.#timeout = setTimeout(() => this.#endSgrQuarantine(), SGR_QUARANTINE_TIMEOUT_MS);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
#endSgrQuarantine(): void {
|
|
508
|
+
if (this.#timeout) clearTimeout(this.#timeout);
|
|
509
|
+
this.#timeout = undefined;
|
|
510
|
+
this.#sgrQuarantine = false;
|
|
511
|
+
this.#sgrQuarantineBytes = 0;
|
|
512
|
+
this.#sgrQuarantineSemicolons = 0;
|
|
513
|
+
this.#sgrQuarantineHasDigit = false;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
#consumeSgrQuarantine(data: string): string {
|
|
517
|
+
for (let index = 0; index < data.length; index += 1) {
|
|
518
|
+
const char = data[index]!;
|
|
519
|
+
if (this.#sgrQuarantineBytes >= SGR_QUARANTINE_MAX_BYTES) {
|
|
520
|
+
let resume = index;
|
|
521
|
+
while (resume < data.length && /[\d;]/u.test(data[resume]!)) resume += 1;
|
|
522
|
+
if (resume < data.length && /[Mm]/u.test(data[resume]!)) resume += 1;
|
|
523
|
+
this.#endSgrQuarantine();
|
|
524
|
+
return data.slice(resume);
|
|
525
|
+
}
|
|
526
|
+
if (/\d/u.test(char)) {
|
|
527
|
+
this.#sgrQuarantineHasDigit = true;
|
|
528
|
+
this.#sgrQuarantineBytes += 1;
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
if (char === ";" && this.#sgrQuarantineHasDigit && this.#sgrQuarantineSemicolons < 2) {
|
|
532
|
+
this.#sgrQuarantineSemicolons += 1;
|
|
533
|
+
this.#sgrQuarantineHasDigit = false;
|
|
534
|
+
this.#sgrQuarantineBytes += 1;
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if ((char === "M" || char === "m") && this.#sgrQuarantineSemicolons === 2 && this.#sgrQuarantineHasDigit) {
|
|
538
|
+
this.#endSgrQuarantine();
|
|
539
|
+
return data.slice(index + 1);
|
|
540
|
+
}
|
|
541
|
+
this.#endSgrQuarantine();
|
|
542
|
+
return data.slice(index);
|
|
543
|
+
}
|
|
544
|
+
return "";
|
|
545
|
+
}
|
|
546
|
+
|
|
462
547
|
#consumePendingSingleUtf8LeadAsMeta(): string | undefined {
|
|
463
548
|
const byte = this.#pendingSingleUtf8LeadByte;
|
|
464
549
|
if (byte === undefined) return undefined;
|
|
@@ -482,6 +567,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
482
567
|
clearTimeout(this.#timeout);
|
|
483
568
|
this.#timeout = undefined;
|
|
484
569
|
}
|
|
570
|
+
if (this.#sgrQuarantine) this.#endSgrQuarantine();
|
|
485
571
|
|
|
486
572
|
const pendingMeta = this.#consumePendingSingleUtf8LeadAsMeta();
|
|
487
573
|
|
|
@@ -489,6 +575,12 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
489
575
|
return pendingMeta === undefined ? [] : [pendingMeta];
|
|
490
576
|
}
|
|
491
577
|
|
|
578
|
+
if (isSgrMousePrefix(this.#buffer)) {
|
|
579
|
+
this.#buffer = "";
|
|
580
|
+
this.#pendingKittyPrintableCodepoint = undefined;
|
|
581
|
+
return pendingMeta === undefined ? [] : [pendingMeta];
|
|
582
|
+
}
|
|
583
|
+
|
|
492
584
|
const sequences = pendingMeta === undefined ? [this.#buffer] : [pendingMeta, this.#buffer];
|
|
493
585
|
this.#buffer = "";
|
|
494
586
|
this.#pendingKittyPrintableCodepoint = undefined;
|
|
@@ -504,6 +596,10 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
504
596
|
this.#pasteMode = false;
|
|
505
597
|
this.#pasteBuffer = "";
|
|
506
598
|
this.#pendingKittyPrintableCodepoint = undefined;
|
|
599
|
+
this.#sgrQuarantine = false;
|
|
600
|
+
this.#sgrQuarantineBytes = 0;
|
|
601
|
+
this.#sgrQuarantineSemicolons = 0;
|
|
602
|
+
this.#sgrQuarantineHasDigit = false;
|
|
507
603
|
// Drop any incomplete multi-byte sequence the decoder is holding so a
|
|
508
604
|
// stale partial prefix cannot combine with future input. destroy()
|
|
509
605
|
// resets the decoder by calling clear().
|
package/src/terminal.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import { $env, $flag } from "@gajae-code/utils";
|
|
4
4
|
import { setKittyProtocolActive } from "./keys";
|
|
5
5
|
import { StdinBuffer } from "./stdin-buffer";
|
|
6
|
+
import { isUnderTerminalMultiplexer } from "./terminal-capabilities";
|
|
6
7
|
|
|
7
8
|
const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
|
|
8
9
|
const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
|
|
@@ -73,6 +74,9 @@ export interface Terminal {
|
|
|
73
74
|
|
|
74
75
|
// Stop the terminal and restore state
|
|
75
76
|
stop(): void;
|
|
77
|
+
// Enable or disable opt-in SGR mouse reporting. Implementations that do not
|
|
78
|
+
// own a real terminal may ignore this.
|
|
79
|
+
setMouseEnabled?(enabled: boolean): void;
|
|
76
80
|
|
|
77
81
|
/**
|
|
78
82
|
* Drain stdin before exiting to prevent Kitty key release events from
|
|
@@ -220,6 +224,8 @@ export class ProcessTerminal implements Terminal {
|
|
|
220
224
|
#osc11PollTimer?: Timer;
|
|
221
225
|
#mode2031DebounceTimer?: Timer;
|
|
222
226
|
#progressTimer?: ReturnType<typeof setInterval>;
|
|
227
|
+
#mouseEnabled = false;
|
|
228
|
+
#started = false;
|
|
223
229
|
|
|
224
230
|
get isProcessTerminal(): boolean {
|
|
225
231
|
return true;
|
|
@@ -237,9 +243,15 @@ export class ProcessTerminal implements Terminal {
|
|
|
237
243
|
this.#appearanceCallbacks.push(callback);
|
|
238
244
|
}
|
|
239
245
|
|
|
246
|
+
setMouseEnabled(enabled: boolean): void {
|
|
247
|
+
this.#mouseEnabled = enabled && !isUnderTerminalMultiplexer(Bun.env);
|
|
248
|
+
if (this.#started) this.#safeWrite(this.#mouseEnabled ? "\x1b[?1000h\x1b[?1006h" : "\x1b[?1000l\x1b[?1006l");
|
|
249
|
+
}
|
|
250
|
+
|
|
240
251
|
start(onInput: (data: string) => void, onResize: () => void): void {
|
|
241
252
|
this.#inputHandler = onInput;
|
|
242
253
|
this.#resizeHandler = onResize;
|
|
254
|
+
this.#started = true;
|
|
243
255
|
|
|
244
256
|
// Register for emergency cleanup
|
|
245
257
|
activeTerminal = this;
|
|
@@ -259,6 +271,8 @@ export class ProcessTerminal implements Terminal {
|
|
|
259
271
|
|
|
260
272
|
// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
|
|
261
273
|
this.#safeWrite("\x1b[?2004h");
|
|
274
|
+
// SGR mouse reporting is opt-in and never enabled inside tmux or screen.
|
|
275
|
+
if (this.#mouseEnabled) this.#safeWrite("\x1b[?1000h\x1b[?1006h");
|
|
262
276
|
|
|
263
277
|
// Set up resize handler immediately
|
|
264
278
|
process.stdout.on("resize", this.#resizeHandler);
|
|
@@ -690,6 +704,8 @@ export class ProcessTerminal implements Terminal {
|
|
|
690
704
|
}
|
|
691
705
|
|
|
692
706
|
// Disable bracketed paste mode
|
|
707
|
+
this.#started = false;
|
|
708
|
+
this.#mouseEnabled = false;
|
|
693
709
|
this.#safeWrite("\x1b[?2004l");
|
|
694
710
|
this.#safeWrite("\x1b[?1000l");
|
|
695
711
|
this.#safeWrite("\x1b[?1006l");
|
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.
|
|
@@ -673,9 +714,14 @@ export class TUI extends Container {
|
|
|
673
714
|
options?: OverlayOptions;
|
|
674
715
|
preFocus: Component | null;
|
|
675
716
|
hidden: boolean;
|
|
717
|
+
mouseBounds?: OverlayMouseBounds;
|
|
676
718
|
}[] = [];
|
|
677
719
|
|
|
678
|
-
constructor(
|
|
720
|
+
constructor(
|
|
721
|
+
terminal: Terminal,
|
|
722
|
+
showHardwareCursor?: boolean,
|
|
723
|
+
private readonly options: { enableMouse?: boolean } = {},
|
|
724
|
+
) {
|
|
679
725
|
super();
|
|
680
726
|
this.terminal = terminal;
|
|
681
727
|
if (showHardwareCursor !== undefined) {
|
|
@@ -770,6 +816,50 @@ export class TUI extends Container {
|
|
|
770
816
|
if (this.#manualViewportAnchor !== null) this.#reconcileMissingViewportAnchor = true;
|
|
771
817
|
}
|
|
772
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
|
+
|
|
773
863
|
scrollViewportPages(direction: -1 | 1): boolean {
|
|
774
864
|
const height = this.terminal.rows;
|
|
775
865
|
const width = this.terminal.columns;
|
|
@@ -878,7 +968,8 @@ export class TUI extends Container {
|
|
|
878
968
|
* Returns a handle to control the overlay's visibility.
|
|
879
969
|
*/
|
|
880
970
|
showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {
|
|
881
|
-
const entry = { component, options, preFocus: this.#focusedComponent, hidden: false };
|
|
971
|
+
const entry = { component, options, preFocus: this.#focusedComponent, hidden: false, mouseBounds: undefined };
|
|
972
|
+
|
|
882
973
|
this.overlayStack.push(entry);
|
|
883
974
|
// Only focus if overlay is actually visible
|
|
884
975
|
if (this.#isOverlayVisible(entry)) {
|
|
@@ -892,6 +983,8 @@ export class TUI extends Container {
|
|
|
892
983
|
hide: () => {
|
|
893
984
|
const index = this.overlayStack.indexOf(entry);
|
|
894
985
|
if (index !== -1) {
|
|
986
|
+
entry.mouseBounds = undefined;
|
|
987
|
+
|
|
895
988
|
this.overlayStack.splice(index, 1);
|
|
896
989
|
// Restore focus if this overlay had focus
|
|
897
990
|
if (this.#focusedComponent === component) {
|
|
@@ -905,6 +998,8 @@ export class TUI extends Container {
|
|
|
905
998
|
setHidden: (hidden: boolean) => {
|
|
906
999
|
if (entry.hidden === hidden) return;
|
|
907
1000
|
entry.hidden = hidden;
|
|
1001
|
+
entry.mouseBounds = undefined;
|
|
1002
|
+
|
|
908
1003
|
// Update focus when hiding/showing
|
|
909
1004
|
if (hidden) {
|
|
910
1005
|
// If this overlay had focus, move focus to next visible or preFocus
|
|
@@ -928,6 +1023,7 @@ export class TUI extends Container {
|
|
|
928
1023
|
hideOverlay(): void {
|
|
929
1024
|
const overlay = this.overlayStack.pop();
|
|
930
1025
|
if (!overlay) return;
|
|
1026
|
+
overlay.mouseBounds = undefined;
|
|
931
1027
|
// Find topmost visible overlay, or fall back to preFocus
|
|
932
1028
|
const topVisible = this.#getTopmostVisibleOverlay();
|
|
933
1029
|
this.setFocus(topVisible?.component ?? overlay.preFocus);
|
|
@@ -962,11 +1058,13 @@ export class TUI extends Container {
|
|
|
962
1058
|
override invalidate(): void {
|
|
963
1059
|
super.invalidate();
|
|
964
1060
|
for (const overlay of this.overlayStack) overlay.component.invalidate?.();
|
|
1061
|
+
for (const overlay of this.overlayStack) overlay.mouseBounds = undefined;
|
|
965
1062
|
}
|
|
966
1063
|
|
|
967
1064
|
start(): void {
|
|
968
1065
|
this.#stopped = false;
|
|
969
1066
|
this.#terminalUnavailable = false;
|
|
1067
|
+
this.terminal.setMouseEnabled?.(this.options.enableMouse === true);
|
|
970
1068
|
this.terminal.start(
|
|
971
1069
|
data => this.#handleInput(data),
|
|
972
1070
|
() => {
|
|
@@ -1371,6 +1469,44 @@ export class TUI extends Container {
|
|
|
1371
1469
|
data = current;
|
|
1372
1470
|
}
|
|
1373
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
|
+
|
|
1374
1510
|
// Consume terminal cell size responses without blocking unrelated input.
|
|
1375
1511
|
if (this.#consumeCellSizeResponse(data)) {
|
|
1376
1512
|
return;
|
|
@@ -1570,6 +1706,7 @@ export class TUI extends Container {
|
|
|
1570
1706
|
#compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] {
|
|
1571
1707
|
if (this.overlayStack.length === 0) return lines;
|
|
1572
1708
|
const result = [...lines];
|
|
1709
|
+
for (const entry of this.overlayStack) entry.mouseBounds = undefined;
|
|
1573
1710
|
|
|
1574
1711
|
// Pre-render all visible overlays and calculate positions
|
|
1575
1712
|
const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = [];
|
|
@@ -1597,6 +1734,7 @@ export class TUI extends Container {
|
|
|
1597
1734
|
const { row, col } = this.#resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);
|
|
1598
1735
|
|
|
1599
1736
|
rendered.push({ overlayLines, row, col, w: width });
|
|
1737
|
+
entry.mouseBounds = { row, col, width, height: overlayLines.length, termWidth, termHeight };
|
|
1600
1738
|
minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
|
|
1601
1739
|
}
|
|
1602
1740
|
|