@gajae-code/tui 0.11.3 → 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 +7 -0
- package/dist/types/bracketed-paste.d.ts +8 -14
- package/package.json +3 -3
- package/src/bracketed-paste.ts +106 -29
- package/src/components/editor.ts +5 -1
- package/src/components/input.ts +5 -1
- package/src/components/secret-input.ts +5 -1
- package/src/keybindings.ts +11 -3
- package/src/stdin-buffer.ts +41 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
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
|
|
|
@@ -1,26 +1,20 @@
|
|
|
1
|
+
export declare const BRACKETED_PASTE_FRAME_TIMEOUT_MS = 1000;
|
|
2
|
+
export declare const BRACKETED_PASTE_FRAME_MAX_BYTES: number;
|
|
1
3
|
export type PasteResult = {
|
|
2
4
|
handled: false;
|
|
3
5
|
} | {
|
|
4
6
|
handled: true;
|
|
7
|
+
leading: string;
|
|
5
8
|
pasteContent?: string;
|
|
6
9
|
remaining: string;
|
|
7
10
|
};
|
|
8
11
|
/**
|
|
9
|
-
* Handles bracketed paste
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* end (\x1b[201~) markers, which may arrive split across multiple chunks.
|
|
13
|
-
* This class buffers incoming data and assembles complete paste payloads.
|
|
12
|
+
* Handles bracketed paste framing with bounded buffering. Leading ordinary
|
|
13
|
+
* input and split markers are retained byte-for-byte; stale or oversized
|
|
14
|
+
* incomplete frames are released as ordinary input on the next event.
|
|
14
15
|
*/
|
|
15
16
|
export declare class BracketedPasteHandler {
|
|
16
17
|
#private;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
*
|
|
20
|
-
* @returns `{ handled: false }` if the data contains no paste sequence and
|
|
21
|
-
* should be processed normally. `{ handled: true }` if the data was
|
|
22
|
-
* consumed by paste buffering — `pasteContent` is set when a complete
|
|
23
|
-
* paste has been assembled; omitted when still buffering.
|
|
24
|
-
*/
|
|
25
|
-
process(data: string): PasteResult;
|
|
18
|
+
get hasPendingFrame(): boolean;
|
|
19
|
+
process(data: string, now?: number): PasteResult;
|
|
26
20
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/tui",
|
|
4
|
-
"version": "0.11.
|
|
4
|
+
"version": "0.11.4",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://gajae-code.com",
|
|
7
7
|
"author": "Yeachan-Heo and Gajae Code Contributors",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"fmt": "biome format --write ."
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@gajae-code/natives": "0.11.
|
|
40
|
-
"@gajae-code/utils": "0.11.
|
|
39
|
+
"@gajae-code/natives": "0.11.4",
|
|
40
|
+
"@gajae-code/utils": "0.11.4",
|
|
41
41
|
"lru-cache": "11.3.6",
|
|
42
42
|
"marked": "18.0.6"
|
|
43
43
|
},
|
package/src/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
|
@@ -1142,8 +1142,12 @@ export class Editor implements Component, Focusable {
|
|
|
1142
1142
|
}
|
|
1143
1143
|
|
|
1144
1144
|
// Handle bracketed paste mode
|
|
1145
|
-
const paste =
|
|
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) {
|
package/src/components/input.ts
CHANGED
|
@@ -64,8 +64,12 @@ export class Input implements Component, Focusable {
|
|
|
64
64
|
|
|
65
65
|
handleInput(data: string): void {
|
|
66
66
|
// Handle bracketed paste mode
|
|
67
|
-
const paste =
|
|
67
|
+
const paste =
|
|
68
|
+
data === "\x1b" && !this.#pasteHandler.hasPendingFrame
|
|
69
|
+
? ({ handled: false } as const)
|
|
70
|
+
: this.#pasteHandler.process(data);
|
|
68
71
|
if (paste.handled) {
|
|
72
|
+
if (paste.leading.length > 0) this.handleInput(paste.leading);
|
|
69
73
|
if (paste.pasteContent !== undefined) {
|
|
70
74
|
this.#handlePaste(paste.pasteContent);
|
|
71
75
|
if (paste.remaining.length > 0) {
|
|
@@ -90,8 +90,12 @@ export class SecretInput implements Component, Focusable {
|
|
|
90
90
|
return;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
const paste =
|
|
93
|
+
const paste =
|
|
94
|
+
data === "\x1b" && !this.#pasteHandler.hasPendingFrame
|
|
95
|
+
? ({ handled: false } as const)
|
|
96
|
+
: this.#pasteHandler.process(data);
|
|
94
97
|
if (paste.handled) {
|
|
98
|
+
if (paste.leading.length > 0) this.handleInput(paste.leading);
|
|
95
99
|
if (paste.pasteContent !== undefined) {
|
|
96
100
|
this.#handlePaste(paste.pasteContent);
|
|
97
101
|
if (paste.remaining.length > 0) {
|
package/src/keybindings.ts
CHANGED
|
@@ -187,6 +187,14 @@ function normalizeKeys(keys: KeyId | KeyId[] | undefined): KeyId[] {
|
|
|
187
187
|
return result;
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
function cloneKeybindingsConfig(config: KeybindingsConfig): KeybindingsConfig {
|
|
191
|
+
const clone: KeybindingsConfig = {};
|
|
192
|
+
for (const [keybinding, keys] of Object.entries(config)) {
|
|
193
|
+
clone[keybinding] = Array.isArray(keys) ? [...keys] : keys;
|
|
194
|
+
}
|
|
195
|
+
return clone;
|
|
196
|
+
}
|
|
197
|
+
|
|
190
198
|
export class KeybindingsManager {
|
|
191
199
|
#definitions: KeybindingDefinitions;
|
|
192
200
|
#userBindings: KeybindingsConfig;
|
|
@@ -195,7 +203,7 @@ export class KeybindingsManager {
|
|
|
195
203
|
|
|
196
204
|
constructor(definitions: KeybindingDefinitions, userBindings: KeybindingsConfig = {}) {
|
|
197
205
|
this.#definitions = definitions;
|
|
198
|
-
this.#userBindings = userBindings;
|
|
206
|
+
this.#userBindings = cloneKeybindingsConfig(userBindings);
|
|
199
207
|
this.#rebuild();
|
|
200
208
|
}
|
|
201
209
|
|
|
@@ -253,12 +261,12 @@ export class KeybindingsManager {
|
|
|
253
261
|
}
|
|
254
262
|
|
|
255
263
|
setUserBindings(userBindings: KeybindingsConfig): void {
|
|
256
|
-
this.#userBindings = userBindings;
|
|
264
|
+
this.#userBindings = cloneKeybindingsConfig(userBindings);
|
|
257
265
|
this.#rebuild();
|
|
258
266
|
}
|
|
259
267
|
|
|
260
268
|
getUserBindings(): KeybindingsConfig {
|
|
261
|
-
return
|
|
269
|
+
return cloneKeybindingsConfig(this.#userBindings);
|
|
262
270
|
}
|
|
263
271
|
|
|
264
272
|
getResolvedBindings(): KeybindingsConfig {
|
package/src/stdin-buffer.ts
CHANGED
|
@@ -35,6 +35,19 @@ export function isSgrMouseSequence(sequence: string): boolean {
|
|
|
35
35
|
function isSgrMousePrefix(sequence: string): boolean {
|
|
36
36
|
return sequence.startsWith(`${ESC}[<`);
|
|
37
37
|
}
|
|
38
|
+
function isHighSurrogate(codeUnit: number): boolean {
|
|
39
|
+
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isLowSurrogate(codeUnit: number): boolean {
|
|
43
|
+
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function singleCodePoint(sequence: string): number | undefined {
|
|
47
|
+
const codepoint = sequence.codePointAt(0);
|
|
48
|
+
if (codepoint === undefined) return undefined;
|
|
49
|
+
return sequence.length === (codepoint > 0xffff ? 2 : 1) ? codepoint : undefined;
|
|
50
|
+
}
|
|
38
51
|
|
|
39
52
|
function isUtf8LeadByte(byte: number): boolean {
|
|
40
53
|
return byte >= 0xc2 && byte <= 0xf4;
|
|
@@ -114,9 +127,9 @@ function isCompleteSequence(data: string): "complete" | "incomplete" | "not-esca
|
|
|
114
127
|
return afterEsc.length >= 2 ? "complete" : "incomplete";
|
|
115
128
|
}
|
|
116
129
|
|
|
117
|
-
// Meta key sequences: ESC followed by a single
|
|
130
|
+
// Meta key sequences: ESC followed by a single Unicode code point
|
|
118
131
|
if (afterEsc.length === 1) {
|
|
119
|
-
return "complete";
|
|
132
|
+
return isHighSurrogate(afterEsc.charCodeAt(0)) ? "incomplete" : "complete";
|
|
120
133
|
}
|
|
121
134
|
|
|
122
135
|
// Unknown escape sequence - treat as complete
|
|
@@ -234,6 +247,18 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
|
|
|
234
247
|
|
|
235
248
|
// Try to extract a sequence starting at this position
|
|
236
249
|
if (remaining.startsWith(ESC)) {
|
|
250
|
+
// A split Meta + supplementary code point stays buffered after its high
|
|
251
|
+
// surrogate. If the next code unit cannot complete that pair, flush the
|
|
252
|
+
// malformed Meta sequence without consuming the following input.
|
|
253
|
+
if (
|
|
254
|
+
remaining.length >= 3 &&
|
|
255
|
+
isHighSurrogate(remaining.charCodeAt(1)) &&
|
|
256
|
+
!isLowSurrogate(remaining.charCodeAt(2))
|
|
257
|
+
) {
|
|
258
|
+
sequences.push(remaining.slice(0, 2));
|
|
259
|
+
pos += 2;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
237
262
|
// Find the end of this escape sequence
|
|
238
263
|
let seqEnd = 1;
|
|
239
264
|
while (seqEnd <= remaining.length) {
|
|
@@ -258,7 +283,19 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
|
|
|
258
283
|
return { sequences, remainder: remaining };
|
|
259
284
|
}
|
|
260
285
|
} else {
|
|
261
|
-
// Not an escape sequence - take a single
|
|
286
|
+
// Not an escape sequence - take a single Unicode code point. Keep a
|
|
287
|
+
// trailing high surrogate buffered so a following string chunk can
|
|
288
|
+
// complete it.
|
|
289
|
+
const firstCodeUnit = remaining.charCodeAt(0);
|
|
290
|
+
if (isHighSurrogate(firstCodeUnit)) {
|
|
291
|
+
if (remaining.length === 1) return { sequences, remainder: remaining };
|
|
292
|
+
const secondCodeUnit = remaining.charCodeAt(1);
|
|
293
|
+
if (isLowSurrogate(secondCodeUnit)) {
|
|
294
|
+
sequences.push(remaining.slice(0, 2));
|
|
295
|
+
pos += 2;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
262
299
|
sequences.push(remaining[0]!);
|
|
263
300
|
pos++;
|
|
264
301
|
}
|
|
@@ -552,7 +589,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
552
589
|
return legacyMetaSequence(byte);
|
|
553
590
|
}
|
|
554
591
|
#emitDataSequence(sequence: string): void {
|
|
555
|
-
const rawCodepoint = sequence
|
|
592
|
+
const rawCodepoint = singleCodePoint(sequence);
|
|
556
593
|
if (rawCodepoint !== undefined && rawCodepoint === this.#pendingKittyPrintableCodepoint) {
|
|
557
594
|
this.#pendingKittyPrintableCodepoint = undefined;
|
|
558
595
|
return;
|