@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 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 mode buffering for terminal input components.
10
- *
11
- * Bracketed paste mode wraps pasted content between start (\x1b[200~) and
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
- * 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;
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.2",
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.2",
40
- "@gajae-code/utils": "0.11.2",
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
  },
@@ -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
 
@@ -1140,8 +1142,12 @@ export class Editor implements Component, Focusable {
1140
1142
  }
1141
1143
 
1142
1144
  // Handle bracketed paste mode
1143
- const paste = this.#pasteHandler.process(data);
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 ((kb.matches(data, "tui.input.submit") || data === "\n") && this.#autocompletePrefix.startsWith("/")) {
1238
- // Check for stale autocomplete state due to debounce
1239
- const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1240
- const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1241
- if (!currentTextBeforeCursor.endsWith(this.#autocompletePrefix)) {
1242
- // Autocomplete is stale - cancel and fall through to normal submission
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 selected = this.#autocompleteList.getSelectedItem();
1246
- if (selected && this.#autocompleteProvider) {
1247
- const result = this.#autocompleteProvider.applyCompletion(
1248
- this.#state.lines,
1249
- this.#state.cursorLine,
1250
- this.#state.cursorCol,
1251
- selected,
1252
- this.#autocompletePrefix,
1253
- );
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
- this.#state.lines = result.lines;
1256
- this.#bumpDocumentVersion();
1257
- this.#state.cursorLine = result.cursorLine;
1258
- this.#setCursorCol(result.cursorCol);
1259
- result.onApplied?.();
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 for slash command tokens.
1867
- if (char === "/" && (this.#isAtStartOfSubmittedMessage() || this.#isInSlashTokenContext())) {
1868
- this.#tryTriggerAutocomplete();
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();
@@ -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) {
@@ -90,8 +90,12 @@ export class SecretInput implements Component, Focusable {
90
90
  return;
91
91
  }
92
92
 
93
- const paste = this.#pasteHandler.process(data);
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) {
@@ -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 { ...this.#userBindings };
269
+ return cloneKeybindingsConfig(this.#userBindings);
262
270
  }
263
271
 
264
272
  getResolvedBindings(): KeybindingsConfig {
@@ -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 character
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 character
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.length === 1 ? sequence.codePointAt(0) : undefined;
592
+ const rawCodepoint = singleCodePoint(sequence);
556
593
  if (rawCodepoint !== undefined && rawCodepoint === this.#pendingKittyPrintableCodepoint) {
557
594
  this.#pendingKittyPrintableCodepoint = undefined;
558
595
  return;