@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.
@@ -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)
@@ -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;
@@ -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.1",
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": "PI_TUI_PERF_GATES=1 bun test test/perf-gates.test.ts",
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.1",
39
- "@gajae-code/utils": "0.11.1",
39
+ "@gajae-code/natives": "0.11.2",
40
+ "@gajae-code/utils": "0.11.2",
40
41
  "lru-cache": "11.3.6",
41
- "marked": "^18.0.3"
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(30, Math.max(...this.#items.map(item => visibleWidth(item.label))));
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 = item.label + padding(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
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),
@@ -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
- const mouseMatch = /^<\d+;\d+;\d+[Mm]$/.test(payload);
141
- if (mouseMatch) {
142
- return "complete";
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
- // Clear any pending timeout
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(terminal: Terminal, showHardwareCursor?: boolean) {
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