@gajae-code/tui 0.11.1 → 0.11.3

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,6 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.11.3] - 2026-07-19
6
+ ### Fixed
7
+
8
+ - Suppressed slash-command and skill autocomplete inside line-local single-backtick code spans while preserving path completion and ordinary slash matching outside literals (#2619).
9
+
5
10
  ## [0.11.0] - 2026-07-15
6
11
  ### Fixed
7
12
 
@@ -1,4 +1,5 @@
1
1
  export declare function getSlashCommandMatchRank(query: string, commandName: string): number;
2
+ export declare function isInsideInlineCodeSpan(text: string): boolean;
2
3
  export declare function extractSlashCommandTokenPrefix(text: string): string | null;
3
4
  export interface AutocompleteItem {
4
5
  value: string;
@@ -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.3",
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.3",
40
+ "@gajae-code/utils": "0.11.3",
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"
@@ -190,10 +190,38 @@ function normalizeSlashCommandText(value: string): string {
190
190
  .replace(/\s+/g, " ");
191
191
  }
192
192
  const NON_COMMAND_SLASH_PREFIX_PRECEDERS = new Set(["/", "\\", ":", ".", "~"]);
193
+ function findOpenInlineCodeSpanStart(text: string): number | null {
194
+ let openDelimiter: number | null = null;
195
+
196
+ for (let i = 0; i < text.length; ) {
197
+ if (text[i] !== "`") {
198
+ i += 1;
199
+ continue;
200
+ }
201
+
202
+ let runEnd = i + 1;
203
+ while (text[runEnd] === "`") runEnd += 1;
204
+ if (runEnd - i !== 1) {
205
+ i = runEnd;
206
+ continue;
207
+ }
208
+
209
+ let backslashCount = 0;
210
+ for (let j = i - 1; j >= 0 && text[j] === "\\"; j -= 1) backslashCount += 1;
211
+ if (backslashCount % 2 === 0) openDelimiter = openDelimiter === null ? i : null;
212
+ i = runEnd;
213
+ }
214
+
215
+ return openDelimiter;
216
+ }
217
+ export function isInsideInlineCodeSpan(text: string): boolean {
218
+ return findOpenInlineCodeSpanStart(text) !== null;
219
+ }
193
220
 
194
221
  export function extractSlashCommandTokenPrefix(text: string): string | null {
195
222
  const slashIndex = text.lastIndexOf("/");
196
223
  if (slashIndex === -1) return null;
224
+ if (isInsideInlineCodeSpan(text.slice(0, slashIndex + 1))) return null;
197
225
 
198
226
  const token = text.slice(slashIndex);
199
227
  if (/[\s]/.test(token)) return null;
@@ -556,7 +584,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
556
584
  }
557
585
 
558
586
  const lastDelimiterIndex = findLastDelimiter(text);
559
- const pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1);
587
+ const inlineCodeStart = findOpenInlineCodeSpanStart(text);
588
+ const prefixStart = Math.max(lastDelimiterIndex, inlineCodeStart ?? -1);
589
+ const pathPrefix = prefixStart === -1 ? text : text.slice(prefixStart + 1);
560
590
 
561
591
  // For forced extraction (Tab key), always return something
562
592
  if (forceExtract) {
@@ -3,6 +3,7 @@ import {
3
3
  type AutocompleteProvider,
4
4
  type CombinedAutocompleteProvider,
5
5
  extractSlashCommandTokenPrefix,
6
+ isInsideInlineCodeSpan,
6
7
  } from "../autocomplete";
7
8
  import { BracketedPasteHandler } from "../bracketed-paste";
8
9
  import { getKeybindings, type KeybindingsManager } from "../keybindings";
@@ -459,6 +460,7 @@ export class Editor implements Component, Focusable {
459
460
  #autocompleteState: "regular" | "force" | null = null;
460
461
  #autocompletePrefix: string = "";
461
462
  #autocompleteRequestId: number = 0;
463
+ #autocompleteOrigin?: { docVersion: number; cursorLine: number; cursorCol: number };
462
464
  #autocompleteMaxVisible: number = 5;
463
465
  onAutocompleteUpdate?: () => void;
464
466
 
@@ -1201,6 +1203,11 @@ export class Editor implements Component, Focusable {
1201
1203
 
1202
1204
  // If Tab was pressed, always apply the selection
1203
1205
  if (kb.matches(data, "tui.input.tab")) {
1206
+ if (!this.#isAutocompleteSelectionCurrent()) {
1207
+ this.#cancelAutocomplete();
1208
+ this.#handleTabCompletion();
1209
+ return;
1210
+ }
1204
1211
  const selected = this.#autocompleteList.getSelectedItem();
1205
1212
  if (selected && this.#autocompleteProvider) {
1206
1213
  const shouldChainSlashCommandAutocomplete = this.#isSlashCommandNameAutocompleteSelection();
@@ -1234,36 +1241,38 @@ export class Editor implements Component, Focusable {
1234
1241
  }
1235
1242
 
1236
1243
  // If Enter was pressed on a slash command, apply completion and submit
1237
- if ((kb.matches(data, "tui.input.submit") || data === "\n") && this.#autocompletePrefix.startsWith("/")) {
1238
- // Check for stale autocomplete state due to debounce
1239
- const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1240
- const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1241
- if (!currentTextBeforeCursor.endsWith(this.#autocompletePrefix)) {
1242
- // Autocomplete is stale - cancel and fall through to normal submission
1244
+ if (
1245
+ (kb.matches(data, "tui.input.submit") || data === "\n") &&
1246
+ this.#autocompleteState === "regular" &&
1247
+ this.#autocompletePrefix.startsWith("/")
1248
+ ) {
1249
+ const selected = this.#autocompleteList.getSelectedItem();
1250
+ if (!this.#isAutocompleteSelectionCurrent()) {
1243
1251
  this.#cancelAutocomplete();
1244
- } else {
1245
- const selected = this.#autocompleteList.getSelectedItem();
1246
- if (selected && this.#autocompleteProvider) {
1247
- const result = this.#autocompleteProvider.applyCompletion(
1248
- this.#state.lines,
1249
- this.#state.cursorLine,
1250
- this.#state.cursorCol,
1251
- selected,
1252
- this.#autocompletePrefix,
1253
- );
1252
+ } else if (selected && this.#autocompleteProvider) {
1253
+ const result = this.#autocompleteProvider.applyCompletion(
1254
+ this.#state.lines,
1255
+ this.#state.cursorLine,
1256
+ this.#state.cursorCol,
1257
+ selected,
1258
+ this.#autocompletePrefix,
1259
+ );
1254
1260
 
1255
- this.#state.lines = result.lines;
1256
- this.#bumpDocumentVersion();
1257
- this.#state.cursorLine = result.cursorLine;
1258
- this.#setCursorCol(result.cursorCol);
1259
- result.onApplied?.();
1260
- }
1261
+ this.#state.lines = result.lines;
1262
+ this.#bumpDocumentVersion();
1263
+ this.#state.cursorLine = result.cursorLine;
1264
+ this.#setCursorCol(result.cursorCol);
1265
+ result.onApplied?.();
1261
1266
  this.#cancelAutocomplete();
1262
1267
  }
1263
1268
  // Don't return - fall through to submission logic
1264
1269
  }
1265
1270
  // If Enter was pressed on a file path, apply completion
1266
1271
  else if (kb.matches(data, "tui.input.submit") || data === "\n") {
1272
+ if (!this.#isAutocompleteSelectionCurrent()) {
1273
+ this.#cancelAutocomplete();
1274
+ return;
1275
+ }
1267
1276
  const selected = this.#autocompleteList.getSelectedItem();
1268
1277
  if (selected && this.#autocompleteProvider) {
1269
1278
  const result = this.#autocompleteProvider.applyCompletion(
@@ -1863,9 +1872,17 @@ export class Editor implements Component, Focusable {
1863
1872
 
1864
1873
  // Check if we should trigger or update autocomplete
1865
1874
  if (!this.#autocompleteState) {
1866
- // Auto-trigger for slash command tokens.
1867
- if (char === "/" && (this.#isAtStartOfSubmittedMessage() || this.#isInSlashTokenContext())) {
1868
- this.#tryTriggerAutocomplete();
1875
+ // Auto-trigger slash commands, or path-only completion inside inline code.
1876
+ if (char === "/") {
1877
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1878
+ const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1879
+ if (this.#isInSubmittedSlashCommandContext()) {
1880
+ this.#tryTriggerAutocomplete();
1881
+ } else if (isInsideInlineCodeSpan(textBeforeCursor)) {
1882
+ this.#forceFileAutocomplete();
1883
+ } else if (this.#isInSlashTokenContext()) {
1884
+ this.#tryTriggerAutocomplete();
1885
+ }
1869
1886
  }
1870
1887
  // Auto-trigger for "@" file reference (fuzzy search)
1871
1888
  else if (char === "@") {
@@ -2771,14 +2788,6 @@ export class Editor implements Component, Focusable {
2771
2788
  return true;
2772
2789
  }
2773
2790
 
2774
- // Slash commands execute only when the submitted prompt starts with the command.
2775
- #isAtStartOfSubmittedMessage(): boolean {
2776
- const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2777
- const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2778
-
2779
- return this.#hasOnlyWhitespaceBeforeCursorLine() && (beforeCursor.trim() === "" || beforeCursor.trim() === "/");
2780
- }
2781
-
2782
2791
  #isInSubmittedSlashCommandContext(): boolean {
2783
2792
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2784
2793
  const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
@@ -2794,6 +2803,30 @@ export class Editor implements Component, Focusable {
2794
2803
  #isInSlashTokenContext(): boolean {
2795
2804
  return this.#getSlashTokenBeforeCursor() !== null;
2796
2805
  }
2806
+ #isAutocompleteSelectionCurrent(): boolean {
2807
+ if (!this.#isAutocompleteOriginCurrent()) return false;
2808
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2809
+ const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
2810
+ if (!textBeforeCursor.endsWith(this.#autocompletePrefix)) return false;
2811
+ if (this.#autocompleteState !== "regular" || !this.#autocompletePrefix.startsWith("/")) return true;
2812
+ return extractSlashCommandTokenPrefix(textBeforeCursor) === this.#autocompletePrefix;
2813
+ }
2814
+ #captureAutocompleteOrigin(): { docVersion: number; cursorLine: number; cursorCol: number } {
2815
+ return {
2816
+ docVersion: this.#docVersion,
2817
+ cursorLine: this.#state.cursorLine,
2818
+ cursorCol: this.#state.cursorCol,
2819
+ };
2820
+ }
2821
+
2822
+ #isAutocompleteOriginCurrent(origin = this.#autocompleteOrigin): boolean {
2823
+ return (
2824
+ origin !== undefined &&
2825
+ origin.docVersion === this.#docVersion &&
2826
+ origin.cursorLine === this.#state.cursorLine &&
2827
+ origin.cursorCol === this.#state.cursorCol
2828
+ );
2829
+ }
2797
2830
 
2798
2831
  #isSlashCommandNameAutocompleteSelection(): boolean {
2799
2832
  if (this.#autocompleteState !== "regular") {
@@ -2835,6 +2868,7 @@ export class Editor implements Component, Focusable {
2835
2868
  }
2836
2869
  }
2837
2870
 
2871
+ const origin = this.#captureAutocompleteOrigin();
2838
2872
  const requestId = ++this.#autocompleteRequestId;
2839
2873
 
2840
2874
  const suggestions = await this.#autocompleteProvider.getSuggestions(
@@ -2842,12 +2876,13 @@ export class Editor implements Component, Focusable {
2842
2876
  this.#state.cursorLine,
2843
2877
  this.#state.cursorCol,
2844
2878
  );
2845
- if (requestId !== this.#autocompleteRequestId) return;
2879
+ if (requestId !== this.#autocompleteRequestId || !this.#isAutocompleteOriginCurrent(origin)) return;
2846
2880
 
2847
2881
  if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
2848
2882
  this.#autocompletePrefix = suggestions.prefix;
2849
2883
  this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
2850
2884
  this.#autocompleteState = "regular";
2885
+ this.#autocompleteOrigin = origin;
2851
2886
  this.onAutocompleteUpdate?.();
2852
2887
  } else {
2853
2888
  this.#cancelAutocomplete();
@@ -2907,13 +2942,14 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
2907
2942
  return;
2908
2943
  }
2909
2944
 
2945
+ const origin = this.#captureAutocompleteOrigin();
2910
2946
  const requestId = ++this.#autocompleteRequestId;
2911
2947
  const suggestions = await provider.getForceFileSuggestions(
2912
2948
  this.#state.lines,
2913
2949
  this.#state.cursorLine,
2914
2950
  this.#state.cursorCol,
2915
2951
  );
2916
- if (requestId !== this.#autocompleteRequestId) return;
2952
+ if (requestId !== this.#autocompleteRequestId || !this.#isAutocompleteOriginCurrent(origin)) return;
2917
2953
 
2918
2954
  if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
2919
2955
  // If there's exactly one suggestion and this was an explicit Tab press, apply it immediately
@@ -2941,6 +2977,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
2941
2977
  this.#autocompletePrefix = suggestions.prefix;
2942
2978
  this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
2943
2979
  this.#autocompleteState = "force";
2980
+ this.#autocompleteOrigin = origin;
2944
2981
  this.onAutocompleteUpdate?.();
2945
2982
  } else {
2946
2983
  this.#cancelAutocomplete();
@@ -2955,6 +2992,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
2955
2992
  this.#autocompleteRequestId += 1;
2956
2993
  this.#autocompleteState = null;
2957
2994
  this.#autocompleteList = undefined;
2995
+ this.#autocompleteOrigin = undefined;
2958
2996
  this.#autocompletePrefix = "";
2959
2997
  if (notifyCancel && wasAutocompleting) {
2960
2998
  this.onAutocompleteCancel?.();
@@ -2974,6 +3012,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
2974
3012
  return;
2975
3013
  }
2976
3014
 
3015
+ const origin = this.#captureAutocompleteOrigin();
2977
3016
  const requestId = ++this.#autocompleteRequestId;
2978
3017
 
2979
3018
  const suggestions = await this.#autocompleteProvider.getSuggestions(
@@ -2981,12 +3020,13 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
2981
3020
  this.#state.cursorLine,
2982
3021
  this.#state.cursorCol,
2983
3022
  );
2984
- if (requestId !== this.#autocompleteRequestId) return;
3023
+ if (requestId !== this.#autocompleteRequestId || !this.#isAutocompleteOriginCurrent(origin)) return;
2985
3024
 
2986
3025
  if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
2987
3026
  this.#autocompletePrefix = suggestions.prefix;
2988
3027
  // Always create new SelectList to ensure update
2989
3028
  this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
3029
+ this.#autocompleteOrigin = origin;
2990
3030
  this.onAutocompleteUpdate?.();
2991
3031
  } else {
2992
3032
  this.#cancelAutocomplete();
@@ -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