Skip to content

fix: resolve CJK composition bug on iOS terminals (backspace packet splitting)#94

Merged
qorzj merged 1 commit into
lessweb:mainfrom
liante0904:fix-ios-cjk-composition
May 20, 2026
Merged

fix: resolve CJK composition bug on iOS terminals (backspace packet splitting)#94
qorzj merged 1 commit into
lessweb:mainfrom
liante0904:fix-ios-cjk-composition

Conversation

@liante0904
Copy link
Copy Markdown
Contributor

Why this is needed

iOS terminal apps such as Moshi and Blink send CJK (Chinese, Japanese, Korean) composed characters in a single TCP packet when the iOS keyboard composes syllables. For example, when a user types the Korean syllable "가" followed by "나", the terminal may transmit the byte sequence:

"가\x7f나"   (character U+AC00 + backspace 0x7f + character U+B098)

The current handleData in useTerminalInput.ts passes the entire concatenated buffer to parseTerminalInput() without splitting. Since parseTerminalInput treats the whole string as a single input event, the intermediate backspace (\x7f) is not interpreted as a delete action, and the composed character sequence gets corrupted. This results in garbled text and broken CJK input on iOS terminals.

This is a long-standing, well-known issue affecting CJK users of Node.js TUI applications.

What was changed

In src/ui/prompt/useTerminalInput.ts, the handleData function now detects packets that contain one or more \x7f (backspace) bytes alongside other characters. When such a combined packet is received:

  1. The packet is split on \x7f.
  2. Each segment is dispatched individually through parseTerminalInput() and the input handler, in order:
    • First the initial character(s)
    • Then a properly parsed backspace event
    • Then the subsequent character(s)
    • (Repeating for multiple composition steps in a single packet)

Normal input (without embedded backspaces) follows the original code path with zero overhead.

Before

const handleData = (data: Buffer | string) => {
  const { input, key } = parseTerminalInput(data);
  handlerRef.current(input, key);
};

After

const handleData = (data: Buffer | string) => {
  const raw = String(data);

  if (raw.includes("\x7f") && raw.length > 1) {
    const parts = raw.split("\x7f");
    if (parts[0]) {
      const { input, key } = parseTerminalInput(parts[0]);
      handlerRef.current(input, key);
    }
    for (let i = 1; i < parts.length; i++) {
      const bs = parseTerminalInput("\x7f");
      handlerRef.current(bs.input, bs.key);
      if (parts[i]) {
        const { input, key } = parseTerminalInput(parts[i]);
        handlerRef.current(input, key);
      }
    }
    return;
  }

  const { input, key } = parseTerminalInput(data);
  handlerRef.current(input, key);
};

Testing

  • npm run typecheck passes with no errors.
  • eslint --fix and prettier --write pass via pre-commit hooks.

@qorzj qorzj merged commit fb68eea into lessweb:main May 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants