@sayknow-cli/tui 0.3.16 → 0.4.0

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.
Files changed (44) hide show
  1. package/README.md +18 -1
  2. package/package.json +6 -7
  3. package/src/autocomplete.ts +31 -1
  4. package/src/bracketed-paste.ts +106 -29
  5. package/src/components/editor.ts +81 -37
  6. package/src/components/input.ts +5 -1
  7. package/src/components/secret-input.ts +489 -0
  8. package/src/components/select-list.ts +138 -33
  9. package/src/index.ts +1 -0
  10. package/src/keybindings.ts +11 -3
  11. package/src/stdin-buffer.ts +153 -20
  12. package/src/terminal.ts +38 -2
  13. package/src/tui.ts +184 -18
  14. package/dist/types/animation-scheduler.d.ts +0 -13
  15. package/dist/types/autocomplete.d.ts +0 -83
  16. package/dist/types/bracketed-paste.d.ts +0 -26
  17. package/dist/types/components/box.d.ts +0 -20
  18. package/dist/types/components/cancellable-loader.d.ts +0 -21
  19. package/dist/types/components/editor.d.ts +0 -126
  20. package/dist/types/components/image.d.ts +0 -18
  21. package/dist/types/components/input.d.ts +0 -16
  22. package/dist/types/components/loader.d.ts +0 -23
  23. package/dist/types/components/markdown.d.ts +0 -87
  24. package/dist/types/components/sayknow-pet.d.ts +0 -128
  25. package/dist/types/components/select-list.d.ts +0 -46
  26. package/dist/types/components/settings-list.d.ts +0 -39
  27. package/dist/types/components/spacer.d.ts +0 -11
  28. package/dist/types/components/tab-bar.d.ts +0 -56
  29. package/dist/types/components/text.d.ts +0 -22
  30. package/dist/types/components/truncated-text.d.ts +0 -10
  31. package/dist/types/editor-component.d.ts +0 -36
  32. package/dist/types/fuzzy.d.ts +0 -15
  33. package/dist/types/index.d.ts +0 -28
  34. package/dist/types/keybindings.d.ts +0 -201
  35. package/dist/types/keys.d.ts +0 -208
  36. package/dist/types/kill-ring.d.ts +0 -27
  37. package/dist/types/metrics.d.ts +0 -85
  38. package/dist/types/stdin-buffer.d.ts +0 -50
  39. package/dist/types/symbols.d.ts +0 -23
  40. package/dist/types/terminal-capabilities.d.ts +0 -187
  41. package/dist/types/terminal.d.ts +0 -90
  42. package/dist/types/ttyid.d.ts +0 -9
  43. package/dist/types/tui.d.ts +0 -269
  44. 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.3.16",
4
+ "version": "0.4.0",
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": "./dist/types/index.d.ts",
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",
@@ -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": "./dist/types/index.d.ts",
60
+ "types": "./src/index.ts",
62
61
  "import": "./src/index.ts"
63
62
  },
64
63
  "./*": {
65
- "types": "./dist/types/*.d.ts",
64
+ "types": "./src/*.ts",
66
65
  "import": "./src/*.ts"
67
66
  },
68
67
  "./components/*": {
69
- "types": "./dist/types/components/*.d.ts",
68
+ "types": "./src/components/*.ts",
70
69
  "import": "./src/components/*.ts"
71
70
  },
72
71
  "./*.js": "./src/*.ts"
@@ -190,10 +190,38 @@ function normalizeSlashCommandText(value: string): string {
190
190
  .replace(/\s+/g, " ");
191
191
  }
192
192
  const NON_COMMAND_SLASH_PREFIX_PRECEDERS = new Set(["/", "\\", ":", ".", "~"]);
193
+ function findOpenInlineCodeSpanStart(text: string): number | null {
194
+ let openDelimiter: number | null = null;
195
+
196
+ for (let i = 0; i < text.length; ) {
197
+ if (text[i] !== "`") {
198
+ i += 1;
199
+ continue;
200
+ }
201
+
202
+ let runEnd = i + 1;
203
+ while (text[runEnd] === "`") runEnd += 1;
204
+ if (runEnd - i !== 1) {
205
+ i = runEnd;
206
+ continue;
207
+ }
208
+
209
+ let backslashCount = 0;
210
+ for (let j = i - 1; j >= 0 && text[j] === "\\"; j -= 1) backslashCount += 1;
211
+ if (backslashCount % 2 === 0) openDelimiter = openDelimiter === null ? i : null;
212
+ i = runEnd;
213
+ }
214
+
215
+ return openDelimiter;
216
+ }
217
+ export function isInsideInlineCodeSpan(text: string): boolean {
218
+ return findOpenInlineCodeSpanStart(text) !== null;
219
+ }
193
220
 
194
221
  export function extractSlashCommandTokenPrefix(text: string): string | null {
195
222
  const slashIndex = text.lastIndexOf("/");
196
223
  if (slashIndex === -1) return null;
224
+ if (isInsideInlineCodeSpan(text.slice(0, slashIndex + 1))) return null;
197
225
 
198
226
  const token = text.slice(slashIndex);
199
227
  if (/[\s]/.test(token)) return null;
@@ -556,7 +584,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
556
584
  }
557
585
 
558
586
  const lastDelimiterIndex = findLastDelimiter(text);
559
- const pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1);
587
+ const inlineCodeStart = findOpenInlineCodeSpanStart(text);
588
+ const prefixStart = Math.max(lastDelimiterIndex, inlineCodeStart ?? -1);
589
+ const pathPrefix = prefixStart === -1 ? text : text.slice(prefixStart + 1);
560
590
 
561
591
  // For forced extraction (Tab key), always return something
562
592
  if (forceExtract) {
@@ -1,47 +1,124 @@
1
1
  const PASTE_START = "\x1b[200~";
2
2
  const PASTE_END = "\x1b[201~";
3
3
 
4
- export type PasteResult = { handled: false } | { handled: true; pasteContent?: string; remaining: string };
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 mode buffering for terminal input components.
8
- *
9
- * Bracketed paste mode wraps pasted content between start (\x1b[200~) and
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
- * Process incoming terminal data for bracketed paste sequences.
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
- if (!this.#active) return { handled: false };
34
+ #reset(): void {
35
+ this.#buffer = "";
36
+ this.#leading = "";
37
+ this.#active = false;
38
+ this.#pendingSince = undefined;
39
+ }
33
40
 
34
- this.#buffer += data;
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
- const endIndex = this.#buffer.indexOf(PASTE_END);
37
- if (endIndex === -1) return { handled: true, remaining: "" };
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
- const pasteContent = this.#buffer.substring(0, endIndex);
40
- const remaining = this.#buffer.substring(endIndex + PASTE_END.length);
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.#buffer = "";
43
- this.#active = false;
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
- return { handled: true, pasteContent, remaining };
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
  }
@@ -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 = this.#pasteHandler.process(data);
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 ((kb.matches(data, "tui.input.submit") || data === "\n") && this.#autocompletePrefix.startsWith("/")) {
1242
- // Check for stale autocomplete state due to debounce
1243
- const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1244
- const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1245
- if (!currentTextBeforeCursor.endsWith(this.#autocompletePrefix)) {
1246
- // Autocomplete is stale - cancel and fall through to normal submission
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 selected = this.#autocompleteList.getSelectedItem();
1250
- if (selected && this.#autocompleteProvider) {
1251
- const result = this.#autocompleteProvider.applyCompletion(
1252
- this.#state.lines,
1253
- this.#state.cursorLine,
1254
- this.#state.cursorCol,
1255
- selected,
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
- this.#state.lines = result.lines;
1260
- this.#bumpDocumentVersion();
1261
- this.#state.cursorLine = result.cursorLine;
1262
- this.#setCursorCol(result.cursorCol);
1263
- result.onApplied?.();
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 for slash command tokens.
1871
- if (char === "/" && (this.#isAtStartOfSubmittedMessage() || this.#isInSlashTokenContext())) {
1872
- this.#tryTriggerAutocomplete();
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();
@@ -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 = this.#pasteHandler.process(data);
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) {