@gajae-code/tui 0.4.1 → 0.4.3
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 +6 -0
- package/dist/types/stdin-buffer.d.ts +7 -0
- package/package.json +3 -3
- package/src/stdin-buffer.ts +38 -4
- package/src/terminal.ts +7 -3
- package/src/ttyid.ts +7 -0
- package/src/tui.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.4.3] - 2026-06-10
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed Korean (and other multi-byte UTF-8) paste mojibake in terminal input: `StdinBuffer` is now the single raw-stdin decoding boundary using a persistent `StringDecoder`, so multi-byte characters split across stdin read boundaries are reassembled instead of producing U+FFFD. `ProcessTerminal` no longer relies on `process.stdin.setEncoding("utf8")` and forwards raw Buffers; the legacy single-high-byte meta conversion is preserved. (#454)
|
|
10
|
+
|
|
5
11
|
## [0.4.0] - 2026-06-06
|
|
6
12
|
|
|
7
13
|
### Changed
|
|
@@ -31,6 +31,13 @@ export type StdinBufferEventMap = {
|
|
|
31
31
|
/**
|
|
32
32
|
* Buffers stdin input and emits complete sequences via the 'data' event.
|
|
33
33
|
* Handles partial escape sequences that arrive across multiple chunks.
|
|
34
|
+
*
|
|
35
|
+
* StdinBuffer is the single raw-stdin decoding boundary: raw terminal bytes
|
|
36
|
+
* enter via `process()` and decoded string events leave via the 'data' and
|
|
37
|
+
* 'paste' events. UTF-8 is decoded exactly once here (using a persistent
|
|
38
|
+
* StringDecoder) so multi-byte characters split across chunk boundaries are
|
|
39
|
+
* reassembled rather than corrupted into U+FFFD. All downstream parsing
|
|
40
|
+
* (escape sequences, bracketed paste, Kitty/CSI, OSC/DA1) operates on strings.
|
|
34
41
|
*/
|
|
35
42
|
export declare class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
36
43
|
#private;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/tui",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.3",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://gaebal-gajae.dev",
|
|
7
7
|
"author": "Yeachan-Heo",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"fmt": "biome format --write ."
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@gajae-code/natives": "0.4.
|
|
42
|
-
"@gajae-code/utils": "0.4.
|
|
41
|
+
"@gajae-code/natives": "0.4.3",
|
|
42
|
+
"@gajae-code/utils": "0.4.3",
|
|
43
43
|
"lru-cache": "11.3.6",
|
|
44
44
|
"marked": "^18.0.3"
|
|
45
45
|
},
|
package/src/stdin-buffer.ts
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* Based on code from OpenTUI (https://github.com/anomalyco/opentui)
|
|
17
17
|
* MIT License - Copyright (c) 2025 opentui
|
|
18
18
|
*/
|
|
19
|
+
|
|
20
|
+
import { StringDecoder } from "node:string_decoder";
|
|
19
21
|
import { EventEmitter } from "events";
|
|
20
22
|
|
|
21
23
|
const ESC = "\x1b";
|
|
@@ -246,6 +248,13 @@ export type StdinBufferEventMap = {
|
|
|
246
248
|
/**
|
|
247
249
|
* Buffers stdin input and emits complete sequences via the 'data' event.
|
|
248
250
|
* Handles partial escape sequences that arrive across multiple chunks.
|
|
251
|
+
*
|
|
252
|
+
* StdinBuffer is the single raw-stdin decoding boundary: raw terminal bytes
|
|
253
|
+
* enter via `process()` and decoded string events leave via the 'data' and
|
|
254
|
+
* 'paste' events. UTF-8 is decoded exactly once here (using a persistent
|
|
255
|
+
* StringDecoder) so multi-byte characters split across chunk boundaries are
|
|
256
|
+
* reassembled rather than corrupted into U+FFFD. All downstream parsing
|
|
257
|
+
* (escape sequences, bracketed paste, Kitty/CSI, OSC/DA1) operates on strings.
|
|
249
258
|
*/
|
|
250
259
|
export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
251
260
|
#buffer: string = "";
|
|
@@ -254,6 +263,11 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
254
263
|
#pasteMode: boolean = false;
|
|
255
264
|
#pasteBuffer: string = "";
|
|
256
265
|
#pendingKittyPrintableCodepoint: number | undefined;
|
|
266
|
+
// Persistent UTF-8 decoder. Holds an incomplete trailing multi-byte
|
|
267
|
+
// sequence between chunks so split reads (e.g. a 3-byte Korean syllable
|
|
268
|
+
// split across two stdin events) reassemble correctly instead of emitting
|
|
269
|
+
// U+FFFD. Reset on clear()/destroy(); never finalized on normal flush.
|
|
270
|
+
#decoder = new StringDecoder("utf8");
|
|
257
271
|
|
|
258
272
|
constructor(options: StdinBufferOptions = {}) {
|
|
259
273
|
super();
|
|
@@ -267,22 +281,38 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
267
281
|
this.#timeout = undefined;
|
|
268
282
|
}
|
|
269
283
|
|
|
270
|
-
//
|
|
271
|
-
//
|
|
284
|
+
// Decode raw bytes into a string. Buffers come from raw stdin; strings
|
|
285
|
+
// come from tests or non-terminal callers and are already decoded.
|
|
272
286
|
let str: string;
|
|
287
|
+
let decodedFromBuffer = false;
|
|
273
288
|
if (Buffer.isBuffer(data)) {
|
|
289
|
+
// Legacy 8-bit meta: an isolated high byte (0x80-0xFF) is treated
|
|
290
|
+
// as ESC + (byte - 128) for Alt/meta compatibility, BEFORE UTF-8
|
|
291
|
+
// decoding. This is the one documented exception to UTF-8 boundary
|
|
292
|
+
// decoding — a lone high byte that is also a valid UTF-8 lead byte
|
|
293
|
+
// is still read as meta — so such a byte is never fed to the decoder.
|
|
274
294
|
if (data.length === 1 && data[0]! > 127) {
|
|
275
295
|
const byte = data[0]! - 128;
|
|
276
296
|
str = `\x1b${String.fromCharCode(byte)}`;
|
|
277
297
|
} else {
|
|
278
|
-
|
|
298
|
+
// Decode through the persistent StringDecoder so a multi-byte
|
|
299
|
+
// sequence split across chunks (e.g. a 3-byte Korean syllable)
|
|
300
|
+
// is reassembled instead of emitting U+FFFD.
|
|
301
|
+
str = this.#decoder.write(data);
|
|
302
|
+
decodedFromBuffer = true;
|
|
279
303
|
}
|
|
280
304
|
} else {
|
|
281
305
|
str = data;
|
|
282
306
|
}
|
|
283
307
|
|
|
284
308
|
if (str.length === 0 && this.#buffer.length === 0) {
|
|
285
|
-
|
|
309
|
+
// A Buffer that decoded to nothing means the decoder is holding an
|
|
310
|
+
// incomplete UTF-8 prefix; emit nothing and wait for the completing
|
|
311
|
+
// bytes. Preserve the historical empty 'data' event for explicit
|
|
312
|
+
// empty-string input only.
|
|
313
|
+
if (!decodedFromBuffer) {
|
|
314
|
+
this.#emitDataSequence("");
|
|
315
|
+
}
|
|
286
316
|
return;
|
|
287
317
|
}
|
|
288
318
|
|
|
@@ -398,6 +428,10 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
398
428
|
this.#pasteMode = false;
|
|
399
429
|
this.#pasteBuffer = "";
|
|
400
430
|
this.#pendingKittyPrintableCodepoint = undefined;
|
|
431
|
+
// Drop any incomplete multi-byte sequence the decoder is holding so a
|
|
432
|
+
// stale partial prefix cannot combine with future input. destroy()
|
|
433
|
+
// resets the decoder by calling clear().
|
|
434
|
+
this.#decoder = new StringDecoder("utf8");
|
|
401
435
|
}
|
|
402
436
|
|
|
403
437
|
getBuffer(): string {
|
package/src/terminal.ts
CHANGED
|
@@ -123,7 +123,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
123
123
|
#modifyOtherKeysActive = false;
|
|
124
124
|
#modifyOtherKeysTimeout?: Timer;
|
|
125
125
|
#stdinBuffer?: StdinBuffer;
|
|
126
|
-
#stdinDataHandler?: (data: string) => void;
|
|
126
|
+
#stdinDataHandler?: (data: string | Buffer) => void;
|
|
127
127
|
#dead = false;
|
|
128
128
|
#writeLogPath = $env.PI_TUI_WRITE_LOG || "";
|
|
129
129
|
#detachLogPath = $env.PI_TUI_TERMINAL_DETACH_LOG || "";
|
|
@@ -165,7 +165,11 @@ export class ProcessTerminal implements Terminal {
|
|
|
165
165
|
if (process.stdin.setRawMode) {
|
|
166
166
|
process.stdin.setRawMode(true);
|
|
167
167
|
}
|
|
168
|
-
|
|
168
|
+
// Do NOT setEncoding("utf8"): raw stdin chunks may split a multi-byte
|
|
169
|
+
// UTF-8 character across reads, and Bun's raw-TTY string decoding does
|
|
170
|
+
// not reliably reassemble them (issue #454 — Korean paste mojibake).
|
|
171
|
+
// StdinBuffer is the single decoding boundary and decodes Buffers via a
|
|
172
|
+
// persistent StringDecoder, so we forward raw Buffers untouched.
|
|
169
173
|
process.stdin.resume();
|
|
170
174
|
|
|
171
175
|
// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
|
|
@@ -420,7 +424,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
420
424
|
});
|
|
421
425
|
|
|
422
426
|
// Handler that pipes stdin data through the buffer
|
|
423
|
-
this.#stdinDataHandler = (data: string) => {
|
|
427
|
+
this.#stdinDataHandler = (data: string | Buffer) => {
|
|
424
428
|
this.#stdinBuffer!.process(data);
|
|
425
429
|
};
|
|
426
430
|
}
|
package/src/ttyid.ts
CHANGED
|
@@ -39,6 +39,13 @@ export function getTtyPath(): string | null {
|
|
|
39
39
|
* Returns null if no terminal can be identified (e.g., piped input).
|
|
40
40
|
*/
|
|
41
41
|
export function getTerminalId(): string | null {
|
|
42
|
+
// Inside tmux the stdin TTY path is unstable (it tracks the attached
|
|
43
|
+
// client, not the pane), which spawns duplicate sessions and breaks
|
|
44
|
+
// /resume. Prefer the stable per-pane TMUX_PANE identifier instead.
|
|
45
|
+
if (process.env.TMUX && process.env.TMUX_PANE) {
|
|
46
|
+
return `tmux-${process.env.TMUX_PANE}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
42
49
|
// TTY device path — most reliable, unique per terminal tab
|
|
43
50
|
if (process.stdin.isTTY) {
|
|
44
51
|
try {
|
package/src/tui.ts
CHANGED
|
@@ -641,6 +641,15 @@ export class TUI extends Container {
|
|
|
641
641
|
} catch {
|
|
642
642
|
this.#markTerminalUnavailable();
|
|
643
643
|
}
|
|
644
|
+
// Teardown: release the retained rendered transcript so a stopped TUI does
|
|
645
|
+
// not pin a flat copy of every emitted line for the process lifetime.
|
|
646
|
+
// Safe across temporary stop/start (Ctrl-Z resume, external editor): start()
|
|
647
|
+
// issues a forced render that rebuilds this state and fully redraws, and
|
|
648
|
+
// focus/listener state is intentionally preserved so input routing survives
|
|
649
|
+
// a resume.
|
|
650
|
+
this.#previousLines = [];
|
|
651
|
+
this.#previousWidth = 0;
|
|
652
|
+
this.#previousHeight = 0;
|
|
644
653
|
}
|
|
645
654
|
|
|
646
655
|
requestRender(force = false, source = "unknown"): void {
|