@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 +5 -0
- package/dist/types/autocomplete.d.ts +1 -0
- 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/autocomplete.ts +31 -1
- package/src/components/editor.ts +76 -36
- 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
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)
|
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.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": "
|
|
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.3",
|
|
40
|
+
"@gajae-code/utils": "0.11.3",
|
|
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"
|
package/src/autocomplete.ts
CHANGED
|
@@ -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
|
|
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) {
|
package/src/components/editor.ts
CHANGED
|
@@ -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 (
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
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
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
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
|
|
1867
|
-
if (char === "/"
|
|
1868
|
-
this.#
|
|
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(
|
|
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
|
|