@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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
- ## [0.11.10] - 2026-07-25
5
+ ## [0.11.11] - 2026-07-26
6
6
 
7
7
  ## [0.11.7] - 2026-07-22
8
8
  ### Fixed
@@ -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;
@@ -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. Drag and button-release reports are ignored. */
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.10",
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.10",
40
- "@gajae-code/utils": "0.11.10",
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 && !isUnderTerminalMultiplexer(Bun.env);
248
- if (this.#started) this.#safeWrite(this.#mouseEnabled ? "\x1b[?1000h\x1b[?1006h" : "\x1b[?1000l\x1b[?1006l");
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
- // SGR mouse reporting is opt-in and never enabled inside tmux or screen.
275
- if (this.#mouseEnabled) this.#safeWrite("\x1b[?1000h\x1b[?1006h");
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
- /** Parse xterm SGR mouse reports. Drag and button-release reports are ignored. */
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
- 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;
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: { enableMouse?: boolean } = {},
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") this.scrollViewportPages(mouse.direction!);
1495
- else {
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