@gajae-code/tui 0.11.2 → 0.11.4
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 +12 -0
- package/dist/types/autocomplete.d.ts +1 -0
- package/dist/types/bracketed-paste.d.ts +8 -14
- package/package.json +3 -3
- package/src/autocomplete.ts +31 -1
- package/src/bracketed-paste.ts +106 -29
- package/src/components/editor.ts +81 -37
- package/src/components/input.ts +5 -1
- package/src/components/secret-input.ts +5 -1
- package/src/keybindings.ts +11 -3
- package/src/stdin-buffer.ts +41 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.11.4] - 2026-07-20
|
|
6
|
+
### Fixed
|
|
7
|
+
|
|
8
|
+
- Keybinding configuration arrays are now defensively copied so external mutations cannot diverge snapshots from resolved key matches.
|
|
9
|
+
- Supplementary Unicode terminal input now crosses the stdin decoding boundary as complete code points instead of separate UTF-16 surrogate events.
|
|
10
|
+
- Bracketed-paste framing now preserves ordinary input before coalesced or split start markers, retains split end markers byte-for-byte, and reprocesses multiple framed pastes in order instead of dropping command prefixes.
|
|
11
|
+
|
|
12
|
+
## [0.11.3] - 2026-07-19
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Suppressed slash-command and skill autocomplete inside line-local single-backtick code spans while preserving path completion and ordinary slash matching outside literals (#2619).
|
|
16
|
+
|
|
5
17
|
## [0.11.0] - 2026-07-15
|
|
6
18
|
### Fixed
|
|
7
19
|
|
|
@@ -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;
|
|
@@ -1,26 +1,20 @@
|
|
|
1
|
+
export declare const BRACKETED_PASTE_FRAME_TIMEOUT_MS = 1000;
|
|
2
|
+
export declare const BRACKETED_PASTE_FRAME_MAX_BYTES: number;
|
|
1
3
|
export type PasteResult = {
|
|
2
4
|
handled: false;
|
|
3
5
|
} | {
|
|
4
6
|
handled: true;
|
|
7
|
+
leading: string;
|
|
5
8
|
pasteContent?: string;
|
|
6
9
|
remaining: string;
|
|
7
10
|
};
|
|
8
11
|
/**
|
|
9
|
-
* Handles bracketed paste
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* end (\x1b[201~) markers, which may arrive split across multiple chunks.
|
|
13
|
-
* This class buffers incoming data and assembles complete paste payloads.
|
|
12
|
+
* Handles bracketed paste framing with bounded buffering. Leading ordinary
|
|
13
|
+
* input and split markers are retained byte-for-byte; stale or oversized
|
|
14
|
+
* incomplete frames are released as ordinary input on the next event.
|
|
14
15
|
*/
|
|
15
16
|
export declare class BracketedPasteHandler {
|
|
16
17
|
#private;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
*
|
|
20
|
-
* @returns `{ handled: false }` if the data contains no paste sequence and
|
|
21
|
-
* should be processed normally. `{ handled: true }` if the data was
|
|
22
|
-
* consumed by paste buffering — `pasteContent` is set when a complete
|
|
23
|
-
* paste has been assembled; omitted when still buffering.
|
|
24
|
-
*/
|
|
25
|
-
process(data: string): PasteResult;
|
|
18
|
+
get hasPendingFrame(): boolean;
|
|
19
|
+
process(data: string, now?: number): PasteResult;
|
|
26
20
|
}
|
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.4",
|
|
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.
|
|
40
|
-
"@gajae-code/utils": "0.11.
|
|
39
|
+
"@gajae-code/natives": "0.11.4",
|
|
40
|
+
"@gajae-code/utils": "0.11.4",
|
|
41
41
|
"lru-cache": "11.3.6",
|
|
42
42
|
"marked": "18.0.6"
|
|
43
43
|
},
|
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/bracketed-paste.ts
CHANGED
|
@@ -1,47 +1,124 @@
|
|
|
1
1
|
const PASTE_START = "\x1b[200~";
|
|
2
2
|
const PASTE_END = "\x1b[201~";
|
|
3
3
|
|
|
4
|
-
export
|
|
4
|
+
export const BRACKETED_PASTE_FRAME_TIMEOUT_MS = 1_000;
|
|
5
|
+
export const BRACKETED_PASTE_FRAME_MAX_BYTES = 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
export type PasteResult =
|
|
8
|
+
| { handled: false }
|
|
9
|
+
| { handled: true; leading: string; pasteContent?: string; remaining: string };
|
|
10
|
+
|
|
11
|
+
function partialStartSuffixLength(value: string): number {
|
|
12
|
+
const maxLength = Math.min(value.length, PASTE_START.length - 1);
|
|
13
|
+
for (let length = maxLength; length > 0; length -= 1) {
|
|
14
|
+
if (value.endsWith(PASTE_START.slice(0, length))) return length;
|
|
15
|
+
}
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
5
18
|
|
|
6
19
|
/**
|
|
7
|
-
* Handles bracketed paste
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* end (\x1b[201~) markers, which may arrive split across multiple chunks.
|
|
11
|
-
* This class buffers incoming data and assembles complete paste payloads.
|
|
20
|
+
* Handles bracketed paste framing with bounded buffering. Leading ordinary
|
|
21
|
+
* input and split markers are retained byte-for-byte; stale or oversized
|
|
22
|
+
* incomplete frames are released as ordinary input on the next event.
|
|
12
23
|
*/
|
|
13
24
|
export class BracketedPasteHandler {
|
|
14
25
|
#buffer = "";
|
|
26
|
+
#leading = "";
|
|
15
27
|
#active = false;
|
|
28
|
+
#pendingSince: number | undefined;
|
|
16
29
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
* @returns `{ handled: false }` if the data contains no paste sequence and
|
|
21
|
-
* should be processed normally. `{ handled: true }` if the data was
|
|
22
|
-
* consumed by paste buffering — `pasteContent` is set when a complete
|
|
23
|
-
* paste has been assembled; omitted when still buffering.
|
|
24
|
-
*/
|
|
25
|
-
process(data: string): PasteResult {
|
|
26
|
-
if (data.includes(PASTE_START)) {
|
|
27
|
-
this.#active = true;
|
|
28
|
-
this.#buffer = "";
|
|
29
|
-
data = data.replace(PASTE_START, "");
|
|
30
|
-
}
|
|
30
|
+
get hasPendingFrame(): boolean {
|
|
31
|
+
return this.#active || this.#buffer.length > 0 || this.#leading.length > 0;
|
|
32
|
+
}
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
#reset(): void {
|
|
35
|
+
this.#buffer = "";
|
|
36
|
+
this.#leading = "";
|
|
37
|
+
this.#active = false;
|
|
38
|
+
this.#pendingSince = undefined;
|
|
39
|
+
}
|
|
33
40
|
|
|
34
|
-
|
|
41
|
+
#flushBuffered(): string {
|
|
42
|
+
const buffered = this.#active
|
|
43
|
+
? `${this.#leading}${PASTE_START}${this.#buffer}`
|
|
44
|
+
: `${this.#leading}${this.#buffer}`;
|
|
45
|
+
this.#reset();
|
|
46
|
+
return buffered;
|
|
47
|
+
}
|
|
35
48
|
|
|
36
|
-
|
|
37
|
-
|
|
49
|
+
#flushActive(remaining: string): PasteResult {
|
|
50
|
+
const leading = this.#leading;
|
|
51
|
+
const pasteContent = this.#buffer;
|
|
52
|
+
this.#reset();
|
|
53
|
+
return { handled: true, leading, pasteContent, remaining };
|
|
54
|
+
}
|
|
38
55
|
|
|
39
|
-
|
|
40
|
-
|
|
56
|
+
process(data: string, now = Date.now()): PasteResult {
|
|
57
|
+
if (
|
|
58
|
+
this.hasPendingFrame &&
|
|
59
|
+
this.#pendingSince !== undefined &&
|
|
60
|
+
now - this.#pendingSince >= BRACKETED_PASTE_FRAME_TIMEOUT_MS
|
|
61
|
+
) {
|
|
62
|
+
return this.#active
|
|
63
|
+
? this.#flushActive(data)
|
|
64
|
+
: { handled: true, leading: `${this.#flushBuffered()}${data}`, remaining: "" };
|
|
65
|
+
}
|
|
66
|
+
if (this.hasPendingFrame && data === "\x1b") {
|
|
67
|
+
return this.#active
|
|
68
|
+
? this.#flushActive(data)
|
|
69
|
+
: { handled: true, leading: `${this.#flushBuffered()}${data}`, remaining: "" };
|
|
70
|
+
}
|
|
41
71
|
|
|
42
|
-
this.#
|
|
43
|
-
|
|
72
|
+
if (!this.#active) {
|
|
73
|
+
const hadBufferedInput = this.hasPendingFrame;
|
|
74
|
+
const combined = this.#buffer + data;
|
|
75
|
+
this.#buffer = "";
|
|
76
|
+
const startIndex = combined.indexOf(PASTE_START);
|
|
77
|
+
if (startIndex === -1) {
|
|
78
|
+
const partialLength = partialStartSuffixLength(combined);
|
|
79
|
+
if (partialLength > 0) {
|
|
80
|
+
const nextLeading = this.#leading + combined.slice(0, -partialLength);
|
|
81
|
+
const nextBuffer = combined.slice(-partialLength);
|
|
82
|
+
if (Buffer.byteLength(nextLeading) + Buffer.byteLength(nextBuffer) > BRACKETED_PASTE_FRAME_MAX_BYTES) {
|
|
83
|
+
this.#reset();
|
|
84
|
+
return { handled: true, leading: `${nextLeading}${nextBuffer}`, remaining: "" };
|
|
85
|
+
}
|
|
86
|
+
this.#leading = nextLeading;
|
|
87
|
+
this.#buffer = nextBuffer;
|
|
88
|
+
this.#pendingSince ??= now;
|
|
89
|
+
return { handled: true, leading: "", remaining: "" };
|
|
90
|
+
}
|
|
91
|
+
if (hadBufferedInput || this.#leading.length > 0) {
|
|
92
|
+
const leading = this.#leading + combined;
|
|
93
|
+
this.#reset();
|
|
94
|
+
return { handled: true, leading, remaining: "" };
|
|
95
|
+
}
|
|
96
|
+
return { handled: false };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
this.#leading += combined.slice(0, startIndex);
|
|
100
|
+
this.#buffer = combined.slice(startIndex + PASTE_START.length);
|
|
101
|
+
this.#active = true;
|
|
102
|
+
this.#pendingSince ??= now;
|
|
103
|
+
} else {
|
|
104
|
+
this.#buffer += data;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const endIndex = this.#buffer.indexOf(PASTE_END);
|
|
108
|
+
if (endIndex === -1) {
|
|
109
|
+
if (
|
|
110
|
+
Buffer.byteLength(this.#leading) + Buffer.byteLength(PASTE_START) + Buffer.byteLength(this.#buffer) >
|
|
111
|
+
BRACKETED_PASTE_FRAME_MAX_BYTES
|
|
112
|
+
) {
|
|
113
|
+
return this.#flushActive("");
|
|
114
|
+
}
|
|
115
|
+
return { handled: true, leading: "", remaining: "" };
|
|
116
|
+
}
|
|
44
117
|
|
|
45
|
-
|
|
118
|
+
const leading = this.#leading;
|
|
119
|
+
const pasteContent = this.#buffer.slice(0, endIndex);
|
|
120
|
+
const remaining = this.#buffer.slice(endIndex + PASTE_END.length);
|
|
121
|
+
this.#reset();
|
|
122
|
+
return { handled: true, leading, pasteContent, remaining };
|
|
46
123
|
}
|
|
47
124
|
}
|
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
|
|
|
@@ -1140,8 +1142,12 @@ export class Editor implements Component, Focusable {
|
|
|
1140
1142
|
}
|
|
1141
1143
|
|
|
1142
1144
|
// Handle bracketed paste mode
|
|
1143
|
-
const paste =
|
|
1145
|
+
const paste =
|
|
1146
|
+
data === "\x1b" && !this.#pasteHandler.hasPendingFrame
|
|
1147
|
+
? ({ handled: false } as const)
|
|
1148
|
+
: this.#pasteHandler.process(data);
|
|
1144
1149
|
if (paste.handled) {
|
|
1150
|
+
if (paste.leading.length > 0) this.handleInput(paste.leading);
|
|
1145
1151
|
if (paste.pasteContent !== undefined) {
|
|
1146
1152
|
this.#handlePaste(paste.pasteContent);
|
|
1147
1153
|
if (paste.remaining.length > 0) {
|
|
@@ -1201,6 +1207,11 @@ export class Editor implements Component, Focusable {
|
|
|
1201
1207
|
|
|
1202
1208
|
// If Tab was pressed, always apply the selection
|
|
1203
1209
|
if (kb.matches(data, "tui.input.tab")) {
|
|
1210
|
+
if (!this.#isAutocompleteSelectionCurrent()) {
|
|
1211
|
+
this.#cancelAutocomplete();
|
|
1212
|
+
this.#handleTabCompletion();
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1204
1215
|
const selected = this.#autocompleteList.getSelectedItem();
|
|
1205
1216
|
if (selected && this.#autocompleteProvider) {
|
|
1206
1217
|
const shouldChainSlashCommandAutocomplete = this.#isSlashCommandNameAutocompleteSelection();
|
|
@@ -1234,36 +1245,38 @@ export class Editor implements Component, Focusable {
|
|
|
1234
1245
|
}
|
|
1235
1246
|
|
|
1236
1247
|
// If Enter was pressed on a slash command, apply completion and submit
|
|
1237
|
-
if (
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1248
|
+
if (
|
|
1249
|
+
(kb.matches(data, "tui.input.submit") || data === "\n") &&
|
|
1250
|
+
this.#autocompleteState === "regular" &&
|
|
1251
|
+
this.#autocompletePrefix.startsWith("/")
|
|
1252
|
+
) {
|
|
1253
|
+
const selected = this.#autocompleteList.getSelectedItem();
|
|
1254
|
+
if (!this.#isAutocompleteSelectionCurrent()) {
|
|
1243
1255
|
this.#cancelAutocomplete();
|
|
1244
|
-
} else {
|
|
1245
|
-
const
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
this.#autocompletePrefix,
|
|
1253
|
-
);
|
|
1256
|
+
} else if (selected && this.#autocompleteProvider) {
|
|
1257
|
+
const result = this.#autocompleteProvider.applyCompletion(
|
|
1258
|
+
this.#state.lines,
|
|
1259
|
+
this.#state.cursorLine,
|
|
1260
|
+
this.#state.cursorCol,
|
|
1261
|
+
selected,
|
|
1262
|
+
this.#autocompletePrefix,
|
|
1263
|
+
);
|
|
1254
1264
|
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
}
|
|
1265
|
+
this.#state.lines = result.lines;
|
|
1266
|
+
this.#bumpDocumentVersion();
|
|
1267
|
+
this.#state.cursorLine = result.cursorLine;
|
|
1268
|
+
this.#setCursorCol(result.cursorCol);
|
|
1269
|
+
result.onApplied?.();
|
|
1261
1270
|
this.#cancelAutocomplete();
|
|
1262
1271
|
}
|
|
1263
1272
|
// Don't return - fall through to submission logic
|
|
1264
1273
|
}
|
|
1265
1274
|
// If Enter was pressed on a file path, apply completion
|
|
1266
1275
|
else if (kb.matches(data, "tui.input.submit") || data === "\n") {
|
|
1276
|
+
if (!this.#isAutocompleteSelectionCurrent()) {
|
|
1277
|
+
this.#cancelAutocomplete();
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1267
1280
|
const selected = this.#autocompleteList.getSelectedItem();
|
|
1268
1281
|
if (selected && this.#autocompleteProvider) {
|
|
1269
1282
|
const result = this.#autocompleteProvider.applyCompletion(
|
|
@@ -1863,9 +1876,17 @@ export class Editor implements Component, Focusable {
|
|
|
1863
1876
|
|
|
1864
1877
|
// Check if we should trigger or update autocomplete
|
|
1865
1878
|
if (!this.#autocompleteState) {
|
|
1866
|
-
// Auto-trigger
|
|
1867
|
-
if (char === "/"
|
|
1868
|
-
this.#
|
|
1879
|
+
// Auto-trigger slash commands, or path-only completion inside inline code.
|
|
1880
|
+
if (char === "/") {
|
|
1881
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1882
|
+
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1883
|
+
if (this.#isInSubmittedSlashCommandContext()) {
|
|
1884
|
+
this.#tryTriggerAutocomplete();
|
|
1885
|
+
} else if (isInsideInlineCodeSpan(textBeforeCursor)) {
|
|
1886
|
+
this.#forceFileAutocomplete();
|
|
1887
|
+
} else if (this.#isInSlashTokenContext()) {
|
|
1888
|
+
this.#tryTriggerAutocomplete();
|
|
1889
|
+
}
|
|
1869
1890
|
}
|
|
1870
1891
|
// Auto-trigger for "@" file reference (fuzzy search)
|
|
1871
1892
|
else if (char === "@") {
|
|
@@ -2771,14 +2792,6 @@ export class Editor implements Component, Focusable {
|
|
|
2771
2792
|
return true;
|
|
2772
2793
|
}
|
|
2773
2794
|
|
|
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
2795
|
#isInSubmittedSlashCommandContext(): boolean {
|
|
2783
2796
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2784
2797
|
const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
@@ -2794,6 +2807,30 @@ export class Editor implements Component, Focusable {
|
|
|
2794
2807
|
#isInSlashTokenContext(): boolean {
|
|
2795
2808
|
return this.#getSlashTokenBeforeCursor() !== null;
|
|
2796
2809
|
}
|
|
2810
|
+
#isAutocompleteSelectionCurrent(): boolean {
|
|
2811
|
+
if (!this.#isAutocompleteOriginCurrent()) return false;
|
|
2812
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2813
|
+
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
2814
|
+
if (!textBeforeCursor.endsWith(this.#autocompletePrefix)) return false;
|
|
2815
|
+
if (this.#autocompleteState !== "regular" || !this.#autocompletePrefix.startsWith("/")) return true;
|
|
2816
|
+
return extractSlashCommandTokenPrefix(textBeforeCursor) === this.#autocompletePrefix;
|
|
2817
|
+
}
|
|
2818
|
+
#captureAutocompleteOrigin(): { docVersion: number; cursorLine: number; cursorCol: number } {
|
|
2819
|
+
return {
|
|
2820
|
+
docVersion: this.#docVersion,
|
|
2821
|
+
cursorLine: this.#state.cursorLine,
|
|
2822
|
+
cursorCol: this.#state.cursorCol,
|
|
2823
|
+
};
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
#isAutocompleteOriginCurrent(origin = this.#autocompleteOrigin): boolean {
|
|
2827
|
+
return (
|
|
2828
|
+
origin !== undefined &&
|
|
2829
|
+
origin.docVersion === this.#docVersion &&
|
|
2830
|
+
origin.cursorLine === this.#state.cursorLine &&
|
|
2831
|
+
origin.cursorCol === this.#state.cursorCol
|
|
2832
|
+
);
|
|
2833
|
+
}
|
|
2797
2834
|
|
|
2798
2835
|
#isSlashCommandNameAutocompleteSelection(): boolean {
|
|
2799
2836
|
if (this.#autocompleteState !== "regular") {
|
|
@@ -2835,6 +2872,7 @@ export class Editor implements Component, Focusable {
|
|
|
2835
2872
|
}
|
|
2836
2873
|
}
|
|
2837
2874
|
|
|
2875
|
+
const origin = this.#captureAutocompleteOrigin();
|
|
2838
2876
|
const requestId = ++this.#autocompleteRequestId;
|
|
2839
2877
|
|
|
2840
2878
|
const suggestions = await this.#autocompleteProvider.getSuggestions(
|
|
@@ -2842,12 +2880,13 @@ export class Editor implements Component, Focusable {
|
|
|
2842
2880
|
this.#state.cursorLine,
|
|
2843
2881
|
this.#state.cursorCol,
|
|
2844
2882
|
);
|
|
2845
|
-
if (requestId !== this.#autocompleteRequestId) return;
|
|
2883
|
+
if (requestId !== this.#autocompleteRequestId || !this.#isAutocompleteOriginCurrent(origin)) return;
|
|
2846
2884
|
|
|
2847
2885
|
if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
|
2848
2886
|
this.#autocompletePrefix = suggestions.prefix;
|
|
2849
2887
|
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
2850
2888
|
this.#autocompleteState = "regular";
|
|
2889
|
+
this.#autocompleteOrigin = origin;
|
|
2851
2890
|
this.onAutocompleteUpdate?.();
|
|
2852
2891
|
} else {
|
|
2853
2892
|
this.#cancelAutocomplete();
|
|
@@ -2907,13 +2946,14 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
|
|
2907
2946
|
return;
|
|
2908
2947
|
}
|
|
2909
2948
|
|
|
2949
|
+
const origin = this.#captureAutocompleteOrigin();
|
|
2910
2950
|
const requestId = ++this.#autocompleteRequestId;
|
|
2911
2951
|
const suggestions = await provider.getForceFileSuggestions(
|
|
2912
2952
|
this.#state.lines,
|
|
2913
2953
|
this.#state.cursorLine,
|
|
2914
2954
|
this.#state.cursorCol,
|
|
2915
2955
|
);
|
|
2916
|
-
if (requestId !== this.#autocompleteRequestId) return;
|
|
2956
|
+
if (requestId !== this.#autocompleteRequestId || !this.#isAutocompleteOriginCurrent(origin)) return;
|
|
2917
2957
|
|
|
2918
2958
|
if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
|
2919
2959
|
// If there's exactly one suggestion and this was an explicit Tab press, apply it immediately
|
|
@@ -2941,6 +2981,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
|
|
2941
2981
|
this.#autocompletePrefix = suggestions.prefix;
|
|
2942
2982
|
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
2943
2983
|
this.#autocompleteState = "force";
|
|
2984
|
+
this.#autocompleteOrigin = origin;
|
|
2944
2985
|
this.onAutocompleteUpdate?.();
|
|
2945
2986
|
} else {
|
|
2946
2987
|
this.#cancelAutocomplete();
|
|
@@ -2955,6 +2996,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
|
|
2955
2996
|
this.#autocompleteRequestId += 1;
|
|
2956
2997
|
this.#autocompleteState = null;
|
|
2957
2998
|
this.#autocompleteList = undefined;
|
|
2999
|
+
this.#autocompleteOrigin = undefined;
|
|
2958
3000
|
this.#autocompletePrefix = "";
|
|
2959
3001
|
if (notifyCancel && wasAutocompleting) {
|
|
2960
3002
|
this.onAutocompleteCancel?.();
|
|
@@ -2974,6 +3016,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
|
|
2974
3016
|
return;
|
|
2975
3017
|
}
|
|
2976
3018
|
|
|
3019
|
+
const origin = this.#captureAutocompleteOrigin();
|
|
2977
3020
|
const requestId = ++this.#autocompleteRequestId;
|
|
2978
3021
|
|
|
2979
3022
|
const suggestions = await this.#autocompleteProvider.getSuggestions(
|
|
@@ -2981,12 +3024,13 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
|
|
2981
3024
|
this.#state.cursorLine,
|
|
2982
3025
|
this.#state.cursorCol,
|
|
2983
3026
|
);
|
|
2984
|
-
if (requestId !== this.#autocompleteRequestId) return;
|
|
3027
|
+
if (requestId !== this.#autocompleteRequestId || !this.#isAutocompleteOriginCurrent(origin)) return;
|
|
2985
3028
|
|
|
2986
3029
|
if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
|
2987
3030
|
this.#autocompletePrefix = suggestions.prefix;
|
|
2988
3031
|
// Always create new SelectList to ensure update
|
|
2989
3032
|
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
3033
|
+
this.#autocompleteOrigin = origin;
|
|
2990
3034
|
this.onAutocompleteUpdate?.();
|
|
2991
3035
|
} else {
|
|
2992
3036
|
this.#cancelAutocomplete();
|
package/src/components/input.ts
CHANGED
|
@@ -64,8 +64,12 @@ export class Input implements Component, Focusable {
|
|
|
64
64
|
|
|
65
65
|
handleInput(data: string): void {
|
|
66
66
|
// Handle bracketed paste mode
|
|
67
|
-
const paste =
|
|
67
|
+
const paste =
|
|
68
|
+
data === "\x1b" && !this.#pasteHandler.hasPendingFrame
|
|
69
|
+
? ({ handled: false } as const)
|
|
70
|
+
: this.#pasteHandler.process(data);
|
|
68
71
|
if (paste.handled) {
|
|
72
|
+
if (paste.leading.length > 0) this.handleInput(paste.leading);
|
|
69
73
|
if (paste.pasteContent !== undefined) {
|
|
70
74
|
this.#handlePaste(paste.pasteContent);
|
|
71
75
|
if (paste.remaining.length > 0) {
|
|
@@ -90,8 +90,12 @@ export class SecretInput implements Component, Focusable {
|
|
|
90
90
|
return;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
const paste =
|
|
93
|
+
const paste =
|
|
94
|
+
data === "\x1b" && !this.#pasteHandler.hasPendingFrame
|
|
95
|
+
? ({ handled: false } as const)
|
|
96
|
+
: this.#pasteHandler.process(data);
|
|
94
97
|
if (paste.handled) {
|
|
98
|
+
if (paste.leading.length > 0) this.handleInput(paste.leading);
|
|
95
99
|
if (paste.pasteContent !== undefined) {
|
|
96
100
|
this.#handlePaste(paste.pasteContent);
|
|
97
101
|
if (paste.remaining.length > 0) {
|
package/src/keybindings.ts
CHANGED
|
@@ -187,6 +187,14 @@ function normalizeKeys(keys: KeyId | KeyId[] | undefined): KeyId[] {
|
|
|
187
187
|
return result;
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
function cloneKeybindingsConfig(config: KeybindingsConfig): KeybindingsConfig {
|
|
191
|
+
const clone: KeybindingsConfig = {};
|
|
192
|
+
for (const [keybinding, keys] of Object.entries(config)) {
|
|
193
|
+
clone[keybinding] = Array.isArray(keys) ? [...keys] : keys;
|
|
194
|
+
}
|
|
195
|
+
return clone;
|
|
196
|
+
}
|
|
197
|
+
|
|
190
198
|
export class KeybindingsManager {
|
|
191
199
|
#definitions: KeybindingDefinitions;
|
|
192
200
|
#userBindings: KeybindingsConfig;
|
|
@@ -195,7 +203,7 @@ export class KeybindingsManager {
|
|
|
195
203
|
|
|
196
204
|
constructor(definitions: KeybindingDefinitions, userBindings: KeybindingsConfig = {}) {
|
|
197
205
|
this.#definitions = definitions;
|
|
198
|
-
this.#userBindings = userBindings;
|
|
206
|
+
this.#userBindings = cloneKeybindingsConfig(userBindings);
|
|
199
207
|
this.#rebuild();
|
|
200
208
|
}
|
|
201
209
|
|
|
@@ -253,12 +261,12 @@ export class KeybindingsManager {
|
|
|
253
261
|
}
|
|
254
262
|
|
|
255
263
|
setUserBindings(userBindings: KeybindingsConfig): void {
|
|
256
|
-
this.#userBindings = userBindings;
|
|
264
|
+
this.#userBindings = cloneKeybindingsConfig(userBindings);
|
|
257
265
|
this.#rebuild();
|
|
258
266
|
}
|
|
259
267
|
|
|
260
268
|
getUserBindings(): KeybindingsConfig {
|
|
261
|
-
return
|
|
269
|
+
return cloneKeybindingsConfig(this.#userBindings);
|
|
262
270
|
}
|
|
263
271
|
|
|
264
272
|
getResolvedBindings(): KeybindingsConfig {
|
package/src/stdin-buffer.ts
CHANGED
|
@@ -35,6 +35,19 @@ export function isSgrMouseSequence(sequence: string): boolean {
|
|
|
35
35
|
function isSgrMousePrefix(sequence: string): boolean {
|
|
36
36
|
return sequence.startsWith(`${ESC}[<`);
|
|
37
37
|
}
|
|
38
|
+
function isHighSurrogate(codeUnit: number): boolean {
|
|
39
|
+
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isLowSurrogate(codeUnit: number): boolean {
|
|
43
|
+
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function singleCodePoint(sequence: string): number | undefined {
|
|
47
|
+
const codepoint = sequence.codePointAt(0);
|
|
48
|
+
if (codepoint === undefined) return undefined;
|
|
49
|
+
return sequence.length === (codepoint > 0xffff ? 2 : 1) ? codepoint : undefined;
|
|
50
|
+
}
|
|
38
51
|
|
|
39
52
|
function isUtf8LeadByte(byte: number): boolean {
|
|
40
53
|
return byte >= 0xc2 && byte <= 0xf4;
|
|
@@ -114,9 +127,9 @@ function isCompleteSequence(data: string): "complete" | "incomplete" | "not-esca
|
|
|
114
127
|
return afterEsc.length >= 2 ? "complete" : "incomplete";
|
|
115
128
|
}
|
|
116
129
|
|
|
117
|
-
// Meta key sequences: ESC followed by a single
|
|
130
|
+
// Meta key sequences: ESC followed by a single Unicode code point
|
|
118
131
|
if (afterEsc.length === 1) {
|
|
119
|
-
return "complete";
|
|
132
|
+
return isHighSurrogate(afterEsc.charCodeAt(0)) ? "incomplete" : "complete";
|
|
120
133
|
}
|
|
121
134
|
|
|
122
135
|
// Unknown escape sequence - treat as complete
|
|
@@ -234,6 +247,18 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
|
|
|
234
247
|
|
|
235
248
|
// Try to extract a sequence starting at this position
|
|
236
249
|
if (remaining.startsWith(ESC)) {
|
|
250
|
+
// A split Meta + supplementary code point stays buffered after its high
|
|
251
|
+
// surrogate. If the next code unit cannot complete that pair, flush the
|
|
252
|
+
// malformed Meta sequence without consuming the following input.
|
|
253
|
+
if (
|
|
254
|
+
remaining.length >= 3 &&
|
|
255
|
+
isHighSurrogate(remaining.charCodeAt(1)) &&
|
|
256
|
+
!isLowSurrogate(remaining.charCodeAt(2))
|
|
257
|
+
) {
|
|
258
|
+
sequences.push(remaining.slice(0, 2));
|
|
259
|
+
pos += 2;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
237
262
|
// Find the end of this escape sequence
|
|
238
263
|
let seqEnd = 1;
|
|
239
264
|
while (seqEnd <= remaining.length) {
|
|
@@ -258,7 +283,19 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
|
|
|
258
283
|
return { sequences, remainder: remaining };
|
|
259
284
|
}
|
|
260
285
|
} else {
|
|
261
|
-
// Not an escape sequence - take a single
|
|
286
|
+
// Not an escape sequence - take a single Unicode code point. Keep a
|
|
287
|
+
// trailing high surrogate buffered so a following string chunk can
|
|
288
|
+
// complete it.
|
|
289
|
+
const firstCodeUnit = remaining.charCodeAt(0);
|
|
290
|
+
if (isHighSurrogate(firstCodeUnit)) {
|
|
291
|
+
if (remaining.length === 1) return { sequences, remainder: remaining };
|
|
292
|
+
const secondCodeUnit = remaining.charCodeAt(1);
|
|
293
|
+
if (isLowSurrogate(secondCodeUnit)) {
|
|
294
|
+
sequences.push(remaining.slice(0, 2));
|
|
295
|
+
pos += 2;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
262
299
|
sequences.push(remaining[0]!);
|
|
263
300
|
pos++;
|
|
264
301
|
}
|
|
@@ -552,7 +589,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
552
589
|
return legacyMetaSequence(byte);
|
|
553
590
|
}
|
|
554
591
|
#emitDataSequence(sequence: string): void {
|
|
555
|
-
const rawCodepoint = sequence
|
|
592
|
+
const rawCodepoint = singleCodePoint(sequence);
|
|
556
593
|
if (rawCodepoint !== undefined && rawCodepoint === this.#pendingKittyPrintableCodepoint) {
|
|
557
594
|
this.#pendingKittyPrintableCodepoint = undefined;
|
|
558
595
|
return;
|