@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 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.1",
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.1",
42
- "@gajae-code/utils": "0.4.1",
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
  },
@@ -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
- // Handle high-byte conversion (for compatibility with parseKeypress)
271
- // If buffer has single byte > 127, convert to ESC + (byte - 128)
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
- str = data.toString();
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
- this.#emitDataSequence("");
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
- process.stdin.setEncoding("utf8");
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 {