@gajae-code/tui 0.11.3 → 0.11.5

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,10 +2,20 @@
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
+
5
12
  ## [0.11.3] - 2026-07-19
6
13
  ### Fixed
7
14
 
15
+ - Keybinding configuration arrays are now defensively copied so external mutations cannot diverge snapshots from resolved key matches.
8
16
  - Suppressed slash-command and skill autocomplete inside line-local single-backtick code spans while preserving path completion and ordinary slash matching outside literals (#2619).
17
+ - Supplementary Unicode terminal input now crosses the stdin decoding boundary as complete code points instead of separate UTF-16 surrogate events.
18
+ - 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.
9
19
 
10
20
  ## [0.11.0] - 2026-07-15
11
21
  ### Fixed
@@ -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.3",
4
+ "version": "0.11.5",
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.3",
40
- "@gajae-code/utils": "0.11.3",
39
+ "@gajae-code/natives": "0.11.5",
40
+ "@gajae-code/utils": "0.11.5",
41
41
  "lru-cache": "11.3.6",
42
42
  "marked": "18.0.6"
43
43
  },
@@ -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
  }
@@ -1142,8 +1142,12 @@ export class Editor implements Component, Focusable {
1142
1142
  }
1143
1143
 
1144
1144
  // Handle bracketed paste mode
1145
- 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);
1146
1149
  if (paste.handled) {
1150
+ if (paste.leading.length > 0) this.handleInput(paste.leading);
1147
1151
  if (paste.pasteContent !== undefined) {
1148
1152
  this.#handlePaste(paste.pasteContent);
1149
1153
  if (paste.remaining.length > 0) {
@@ -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;