@sayknow-cli/tui 0.3.16 → 0.4.1
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/README.md +18 -1
- package/package.json +8 -9
- 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 +489 -0
- package/src/components/select-list.ts +138 -33
- package/src/index.ts +1 -0
- package/src/keybindings.ts +11 -3
- package/src/stdin-buffer.ts +153 -20
- package/src/terminal.ts +38 -2
- package/src/tui.ts +184 -18
- package/dist/types/animation-scheduler.d.ts +0 -13
- package/dist/types/autocomplete.d.ts +0 -83
- package/dist/types/bracketed-paste.d.ts +0 -26
- package/dist/types/components/box.d.ts +0 -20
- package/dist/types/components/cancellable-loader.d.ts +0 -21
- package/dist/types/components/editor.d.ts +0 -126
- package/dist/types/components/image.d.ts +0 -18
- package/dist/types/components/input.d.ts +0 -16
- package/dist/types/components/loader.d.ts +0 -23
- package/dist/types/components/markdown.d.ts +0 -87
- package/dist/types/components/sayknow-pet.d.ts +0 -128
- package/dist/types/components/select-list.d.ts +0 -46
- package/dist/types/components/settings-list.d.ts +0 -39
- package/dist/types/components/spacer.d.ts +0 -11
- package/dist/types/components/tab-bar.d.ts +0 -56
- package/dist/types/components/text.d.ts +0 -22
- package/dist/types/components/truncated-text.d.ts +0 -10
- package/dist/types/editor-component.d.ts +0 -36
- package/dist/types/fuzzy.d.ts +0 -15
- package/dist/types/index.d.ts +0 -28
- package/dist/types/keybindings.d.ts +0 -201
- package/dist/types/keys.d.ts +0 -208
- package/dist/types/kill-ring.d.ts +0 -27
- package/dist/types/metrics.d.ts +0 -85
- package/dist/types/stdin-buffer.d.ts +0 -50
- package/dist/types/symbols.d.ts +0 -23
- package/dist/types/terminal-capabilities.d.ts +0 -187
- package/dist/types/terminal.d.ts +0 -90
- package/dist/types/ttyid.d.ts +0 -9
- package/dist/types/tui.d.ts +0 -269
- package/dist/types/utils.d.ts +0 -110
package/README.md
CHANGED
|
@@ -328,6 +328,8 @@ interface SelectItem {
|
|
|
328
328
|
value: string;
|
|
329
329
|
label: string;
|
|
330
330
|
description?: string;
|
|
331
|
+
hint?: string; // Autocomplete hint consumed by Editor; SelectList does not render it
|
|
332
|
+
disabled?: boolean; // Dimmed, unselectable entry (see "Disabled items")
|
|
331
333
|
}
|
|
332
334
|
|
|
333
335
|
interface SelectListTheme {
|
|
@@ -352,14 +354,29 @@ list.onSelect = (item) => console.log("Selected:", item);
|
|
|
352
354
|
list.onCancel = () => console.log("Cancelled");
|
|
353
355
|
list.onSelectionChange = (item) => console.log("Highlighted:", item);
|
|
354
356
|
list.setFilter("opt"); // Filter items
|
|
357
|
+
list.setSelectedIndex(1); // Select first enabled item at/after index 1, then search backward
|
|
355
358
|
```
|
|
356
359
|
|
|
357
360
|
**Controls:**
|
|
358
361
|
|
|
359
|
-
- Arrow keys: Navigate
|
|
362
|
+
- Arrow keys: Navigate and wrap at list edges
|
|
363
|
+
- PageUp/PageDown: Move by a visible page and clamp at list boundaries
|
|
360
364
|
- Enter: Select
|
|
361
365
|
- Escape: Cancel
|
|
362
366
|
|
|
367
|
+
**Disabled items:**
|
|
368
|
+
|
|
369
|
+
Items with `disabled: true` stay visible but can never be selected:
|
|
370
|
+
|
|
371
|
+
- They render dimmed (via `theme.description`) and never show the selection cursor.
|
|
372
|
+
- Arrow keys wrap while skipping disabled entries; PageUp/PageDown skip disabled targets and clamp at list boundaries.
|
|
373
|
+
- Filtering (`setFilter`) resets the selection to the first *enabled* item.
|
|
374
|
+
- `setSelectedIndex(i)` selects the first enabled item at or after the clamped index, falling back backward.
|
|
375
|
+
- `onSelect` and `onSelectionChange` never receive a disabled item.
|
|
376
|
+
- When every visible item is disabled, `getSelectedItem()` returns `null` and no
|
|
377
|
+
row shows a cursor. Arrow keys wrap the viewport, PageUp/PageDown clamp it,
|
|
378
|
+
and the scroll indicator reports `(-/N)` without claiming a selected row.
|
|
379
|
+
|
|
363
380
|
### SettingsList
|
|
364
381
|
|
|
365
382
|
Settings panel with value cycling and submenus.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@sayknow-cli/tui",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.1",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://sayknow-cli.com",
|
|
7
7
|
"author": "jaybeyond",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"cli"
|
|
28
28
|
],
|
|
29
29
|
"main": "./src/index.ts",
|
|
30
|
-
"types": "./
|
|
30
|
+
"types": "./src/index.ts",
|
|
31
31
|
"scripts": {
|
|
32
32
|
"check": "biome check . && bun run check:types",
|
|
33
33
|
"check:types": "tsgo -p tsconfig.json --noEmit",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"fmt": "biome format --write ."
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@sayknow-cli/natives": "0.
|
|
42
|
-
"@sayknow-cli/utils": "0.
|
|
41
|
+
"@sayknow-cli/natives": "0.4.1",
|
|
42
|
+
"@sayknow-cli/utils": "0.4.1",
|
|
43
43
|
"lru-cache": "11.3.6",
|
|
44
44
|
"marked": "^18.0.3"
|
|
45
45
|
},
|
|
@@ -53,20 +53,19 @@
|
|
|
53
53
|
"files": [
|
|
54
54
|
"src",
|
|
55
55
|
"README.md",
|
|
56
|
-
"CHANGELOG.md"
|
|
57
|
-
"dist/types"
|
|
56
|
+
"CHANGELOG.md"
|
|
58
57
|
],
|
|
59
58
|
"exports": {
|
|
60
59
|
".": {
|
|
61
|
-
"types": "./
|
|
60
|
+
"types": "./src/index.ts",
|
|
62
61
|
"import": "./src/index.ts"
|
|
63
62
|
},
|
|
64
63
|
"./*": {
|
|
65
|
-
"types": "./
|
|
64
|
+
"types": "./src/*.ts",
|
|
66
65
|
"import": "./src/*.ts"
|
|
67
66
|
},
|
|
68
67
|
"./components/*": {
|
|
69
|
-
"types": "./
|
|
68
|
+
"types": "./src/components/*.ts",
|
|
70
69
|
"import": "./src/components/*.ts"
|
|
71
70
|
},
|
|
72
71
|
"./*.js": "./src/*.ts"
|
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
|
|
|
@@ -1144,8 +1146,12 @@ export class Editor implements Component, Focusable {
|
|
|
1144
1146
|
}
|
|
1145
1147
|
|
|
1146
1148
|
// Handle bracketed paste mode
|
|
1147
|
-
const paste =
|
|
1149
|
+
const paste =
|
|
1150
|
+
data === "\x1b" && !this.#pasteHandler.hasPendingFrame
|
|
1151
|
+
? ({ handled: false } as const)
|
|
1152
|
+
: this.#pasteHandler.process(data);
|
|
1148
1153
|
if (paste.handled) {
|
|
1154
|
+
if (paste.leading.length > 0) this.handleInput(paste.leading);
|
|
1149
1155
|
if (paste.pasteContent !== undefined) {
|
|
1150
1156
|
this.#handlePaste(paste.pasteContent);
|
|
1151
1157
|
if (paste.remaining.length > 0) {
|
|
@@ -1205,6 +1211,11 @@ export class Editor implements Component, Focusable {
|
|
|
1205
1211
|
|
|
1206
1212
|
// If Tab was pressed, always apply the selection
|
|
1207
1213
|
if (kb.matches(data, "tui.input.tab")) {
|
|
1214
|
+
if (!this.#isAutocompleteSelectionCurrent()) {
|
|
1215
|
+
this.#cancelAutocomplete();
|
|
1216
|
+
this.#handleTabCompletion();
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1208
1219
|
const selected = this.#autocompleteList.getSelectedItem();
|
|
1209
1220
|
if (selected && this.#autocompleteProvider) {
|
|
1210
1221
|
const shouldChainSlashCommandAutocomplete = this.#isSlashCommandNameAutocompleteSelection();
|
|
@@ -1238,36 +1249,38 @@ export class Editor implements Component, Focusable {
|
|
|
1238
1249
|
}
|
|
1239
1250
|
|
|
1240
1251
|
// If Enter was pressed on a slash command, apply completion and submit
|
|
1241
|
-
if (
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1252
|
+
if (
|
|
1253
|
+
(kb.matches(data, "tui.input.submit") || data === "\n") &&
|
|
1254
|
+
this.#autocompleteState === "regular" &&
|
|
1255
|
+
this.#autocompletePrefix.startsWith("/")
|
|
1256
|
+
) {
|
|
1257
|
+
const selected = this.#autocompleteList.getSelectedItem();
|
|
1258
|
+
if (!this.#isAutocompleteSelectionCurrent()) {
|
|
1247
1259
|
this.#cancelAutocomplete();
|
|
1248
|
-
} else {
|
|
1249
|
-
const
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
this.#autocompletePrefix,
|
|
1257
|
-
);
|
|
1260
|
+
} else if (selected && this.#autocompleteProvider) {
|
|
1261
|
+
const result = this.#autocompleteProvider.applyCompletion(
|
|
1262
|
+
this.#state.lines,
|
|
1263
|
+
this.#state.cursorLine,
|
|
1264
|
+
this.#state.cursorCol,
|
|
1265
|
+
selected,
|
|
1266
|
+
this.#autocompletePrefix,
|
|
1267
|
+
);
|
|
1258
1268
|
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
}
|
|
1269
|
+
this.#state.lines = result.lines;
|
|
1270
|
+
this.#bumpDocumentVersion();
|
|
1271
|
+
this.#state.cursorLine = result.cursorLine;
|
|
1272
|
+
this.#setCursorCol(result.cursorCol);
|
|
1273
|
+
result.onApplied?.();
|
|
1265
1274
|
this.#cancelAutocomplete();
|
|
1266
1275
|
}
|
|
1267
1276
|
// Don't return - fall through to submission logic
|
|
1268
1277
|
}
|
|
1269
1278
|
// If Enter was pressed on a file path, apply completion
|
|
1270
1279
|
else if (kb.matches(data, "tui.input.submit") || data === "\n") {
|
|
1280
|
+
if (!this.#isAutocompleteSelectionCurrent()) {
|
|
1281
|
+
this.#cancelAutocomplete();
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1271
1284
|
const selected = this.#autocompleteList.getSelectedItem();
|
|
1272
1285
|
if (selected && this.#autocompleteProvider) {
|
|
1273
1286
|
const result = this.#autocompleteProvider.applyCompletion(
|
|
@@ -1867,9 +1880,17 @@ export class Editor implements Component, Focusable {
|
|
|
1867
1880
|
|
|
1868
1881
|
// Check if we should trigger or update autocomplete
|
|
1869
1882
|
if (!this.#autocompleteState) {
|
|
1870
|
-
// Auto-trigger
|
|
1871
|
-
if (char === "/"
|
|
1872
|
-
this.#
|
|
1883
|
+
// Auto-trigger slash commands, or path-only completion inside inline code.
|
|
1884
|
+
if (char === "/") {
|
|
1885
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1886
|
+
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1887
|
+
if (this.#isInSubmittedSlashCommandContext()) {
|
|
1888
|
+
this.#tryTriggerAutocomplete();
|
|
1889
|
+
} else if (isInsideInlineCodeSpan(textBeforeCursor)) {
|
|
1890
|
+
this.#forceFileAutocomplete();
|
|
1891
|
+
} else if (this.#isInSlashTokenContext()) {
|
|
1892
|
+
this.#tryTriggerAutocomplete();
|
|
1893
|
+
}
|
|
1873
1894
|
}
|
|
1874
1895
|
// Auto-trigger for "@" file reference (fuzzy search)
|
|
1875
1896
|
else if (char === "@") {
|
|
@@ -2775,14 +2796,6 @@ export class Editor implements Component, Focusable {
|
|
|
2775
2796
|
return true;
|
|
2776
2797
|
}
|
|
2777
2798
|
|
|
2778
|
-
// Slash commands execute only when the submitted prompt starts with the command.
|
|
2779
|
-
#isAtStartOfSubmittedMessage(): boolean {
|
|
2780
|
-
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2781
|
-
const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
2782
|
-
|
|
2783
|
-
return this.#hasOnlyWhitespaceBeforeCursorLine() && (beforeCursor.trim() === "" || beforeCursor.trim() === "/");
|
|
2784
|
-
}
|
|
2785
|
-
|
|
2786
2799
|
#isInSubmittedSlashCommandContext(): boolean {
|
|
2787
2800
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2788
2801
|
const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
@@ -2798,6 +2811,30 @@ export class Editor implements Component, Focusable {
|
|
|
2798
2811
|
#isInSlashTokenContext(): boolean {
|
|
2799
2812
|
return this.#getSlashTokenBeforeCursor() !== null;
|
|
2800
2813
|
}
|
|
2814
|
+
#isAutocompleteSelectionCurrent(): boolean {
|
|
2815
|
+
if (!this.#isAutocompleteOriginCurrent()) return false;
|
|
2816
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2817
|
+
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
2818
|
+
if (!textBeforeCursor.endsWith(this.#autocompletePrefix)) return false;
|
|
2819
|
+
if (this.#autocompleteState !== "regular" || !this.#autocompletePrefix.startsWith("/")) return true;
|
|
2820
|
+
return extractSlashCommandTokenPrefix(textBeforeCursor) === this.#autocompletePrefix;
|
|
2821
|
+
}
|
|
2822
|
+
#captureAutocompleteOrigin(): { docVersion: number; cursorLine: number; cursorCol: number } {
|
|
2823
|
+
return {
|
|
2824
|
+
docVersion: this.#docVersion,
|
|
2825
|
+
cursorLine: this.#state.cursorLine,
|
|
2826
|
+
cursorCol: this.#state.cursorCol,
|
|
2827
|
+
};
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
#isAutocompleteOriginCurrent(origin = this.#autocompleteOrigin): boolean {
|
|
2831
|
+
return (
|
|
2832
|
+
origin !== undefined &&
|
|
2833
|
+
origin.docVersion === this.#docVersion &&
|
|
2834
|
+
origin.cursorLine === this.#state.cursorLine &&
|
|
2835
|
+
origin.cursorCol === this.#state.cursorCol
|
|
2836
|
+
);
|
|
2837
|
+
}
|
|
2801
2838
|
|
|
2802
2839
|
#isSlashCommandNameAutocompleteSelection(): boolean {
|
|
2803
2840
|
if (this.#autocompleteState !== "regular") {
|
|
@@ -2839,6 +2876,7 @@ export class Editor implements Component, Focusable {
|
|
|
2839
2876
|
}
|
|
2840
2877
|
}
|
|
2841
2878
|
|
|
2879
|
+
const origin = this.#captureAutocompleteOrigin();
|
|
2842
2880
|
const requestId = ++this.#autocompleteRequestId;
|
|
2843
2881
|
|
|
2844
2882
|
const suggestions = await this.#autocompleteProvider.getSuggestions(
|
|
@@ -2846,12 +2884,13 @@ export class Editor implements Component, Focusable {
|
|
|
2846
2884
|
this.#state.cursorLine,
|
|
2847
2885
|
this.#state.cursorCol,
|
|
2848
2886
|
);
|
|
2849
|
-
if (requestId !== this.#autocompleteRequestId) return;
|
|
2887
|
+
if (requestId !== this.#autocompleteRequestId || !this.#isAutocompleteOriginCurrent(origin)) return;
|
|
2850
2888
|
|
|
2851
2889
|
if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
|
2852
2890
|
this.#autocompletePrefix = suggestions.prefix;
|
|
2853
2891
|
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
2854
2892
|
this.#autocompleteState = "regular";
|
|
2893
|
+
this.#autocompleteOrigin = origin;
|
|
2855
2894
|
this.onAutocompleteUpdate?.();
|
|
2856
2895
|
} else {
|
|
2857
2896
|
this.#cancelAutocomplete();
|
|
@@ -2911,13 +2950,14 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
|
|
2911
2950
|
return;
|
|
2912
2951
|
}
|
|
2913
2952
|
|
|
2953
|
+
const origin = this.#captureAutocompleteOrigin();
|
|
2914
2954
|
const requestId = ++this.#autocompleteRequestId;
|
|
2915
2955
|
const suggestions = await provider.getForceFileSuggestions(
|
|
2916
2956
|
this.#state.lines,
|
|
2917
2957
|
this.#state.cursorLine,
|
|
2918
2958
|
this.#state.cursorCol,
|
|
2919
2959
|
);
|
|
2920
|
-
if (requestId !== this.#autocompleteRequestId) return;
|
|
2960
|
+
if (requestId !== this.#autocompleteRequestId || !this.#isAutocompleteOriginCurrent(origin)) return;
|
|
2921
2961
|
|
|
2922
2962
|
if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
|
2923
2963
|
// If there's exactly one suggestion and this was an explicit Tab press, apply it immediately
|
|
@@ -2945,6 +2985,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
|
|
2945
2985
|
this.#autocompletePrefix = suggestions.prefix;
|
|
2946
2986
|
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
2947
2987
|
this.#autocompleteState = "force";
|
|
2988
|
+
this.#autocompleteOrigin = origin;
|
|
2948
2989
|
this.onAutocompleteUpdate?.();
|
|
2949
2990
|
} else {
|
|
2950
2991
|
this.#cancelAutocomplete();
|
|
@@ -2959,6 +3000,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
|
|
2959
3000
|
this.#autocompleteRequestId += 1;
|
|
2960
3001
|
this.#autocompleteState = null;
|
|
2961
3002
|
this.#autocompleteList = undefined;
|
|
3003
|
+
this.#autocompleteOrigin = undefined;
|
|
2962
3004
|
this.#autocompletePrefix = "";
|
|
2963
3005
|
if (notifyCancel && wasAutocompleting) {
|
|
2964
3006
|
this.onAutocompleteCancel?.();
|
|
@@ -2978,6 +3020,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
|
|
2978
3020
|
return;
|
|
2979
3021
|
}
|
|
2980
3022
|
|
|
3023
|
+
const origin = this.#captureAutocompleteOrigin();
|
|
2981
3024
|
const requestId = ++this.#autocompleteRequestId;
|
|
2982
3025
|
|
|
2983
3026
|
const suggestions = await this.#autocompleteProvider.getSuggestions(
|
|
@@ -2985,12 +3028,13 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
|
|
2985
3028
|
this.#state.cursorLine,
|
|
2986
3029
|
this.#state.cursorCol,
|
|
2987
3030
|
);
|
|
2988
|
-
if (requestId !== this.#autocompleteRequestId) return;
|
|
3031
|
+
if (requestId !== this.#autocompleteRequestId || !this.#isAutocompleteOriginCurrent(origin)) return;
|
|
2989
3032
|
|
|
2990
3033
|
if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
|
2991
3034
|
this.#autocompletePrefix = suggestions.prefix;
|
|
2992
3035
|
// Always create new SelectList to ensure update
|
|
2993
3036
|
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
3037
|
+
this.#autocompleteOrigin = origin;
|
|
2994
3038
|
this.onAutocompleteUpdate?.();
|
|
2995
3039
|
} else {
|
|
2996
3040
|
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) {
|