From 949099d7db82d4c051dff672caf520b0488b49d5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:42:44 -0700 Subject: [PATCH] fix(mobile): recompose iOS decomposed (NFD) Hangul before it reaches the PTY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple text input hands React Native decomposed text, so one Hangul syllable arrives as 2-3 conjoining jamo (안 = U+110B U+1161 U+11AB). The live-input mirror holds only the trailing code point as the in-composition syllable, so every leading jamo is committed to the PTY on its own and is never recombined: typing 안녕하세요 lands 12 lone jamo instead of 5 syllables. This is a third mechanism behind the #6995 symptom, distinct from the Samsung Keyboard inputType kill on Android — the iOS field itself composes correctly, it is the string handed to JS that is decomposed, which is why the report survives autoCorrect={false}. NFC-normalize at the existing normalizeTerminalTextInput choke point, which covers both the live mirror and the command-bar send. NFC only, never NFKC: NFKC folds compatibility jamo and full-width forms into different characters and would silently rewrite a command. The field write-back stays verbatim, so the iOS dictation/IME constraint from #7925 is untouched. Fixes #11235 Refs #6995, #7925 --- .../terminal-ios-hangul-nfd-input.test.ts | 95 +++++++++++++++++++ .../terminal-text-input-normalization.test.ts | 25 +++++ .../terminal-text-input-normalization.ts | 7 +- 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 mobile/src/terminal/terminal-ios-hangul-nfd-input.test.ts diff --git a/mobile/src/terminal/terminal-ios-hangul-nfd-input.test.ts b/mobile/src/terminal/terminal-ios-hangul-nfd-input.test.ts new file mode 100644 index 00000000000..d3492a79116 --- /dev/null +++ b/mobile/src/terminal/terminal-ios-hangul-nfd-input.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { + buildTerminalLiveMirrorPayload, + computeTerminalLiveMirrorStep +} from './terminal-live-hangul-mirror' +import { normalizeTerminalTextInput } from './terminal-text-input-normalization' + +// 두벌식 (2-set) composition states for 안녕하세요, as the field shows them +// keystroke by keystroke: ㅇ ㅏ ㄴ ㄴ ㅕ ㅇ ㅎ ㅏ ㅅ ㅔ ㅇ ㅛ. +const DUBEOLSIK_FIELD_STATES = [ + 'ㅇ', + '아', + '안', + '안ㄴ', + '안녀', + '안녕', + '안녕ㅎ', + '안녕하', + '안녕핫', + '안녕하세', + '안녕하셍', + '안녕하세요' +] as const + +const DEL = '\x7f' + +// Minimal PTY line model: DEL erases one code point, everything else appends. +function applyToPty(pty: string, payload: string): string { + let next = Array.from(pty) + for (const codePoint of Array.from(payload)) { + if (codePoint === DEL) { + next = next.slice(0, -1) + continue + } + next.push(codePoint) + } + return next.join('') +} + +// Replays handleLiveInputChange → applyLiveInputMirror, then the settle-timer +// commit that flushes the last held syllable. +function replayLiveInput(fieldStates: readonly string[]): string { + let sentText = '' + let heldText = '' + let pty = '' + + const step = (fieldText: string, commitHeld: boolean): void => { + const mirror = computeTerminalLiveMirrorStep(sentText, normalizeTerminalTextInput(fieldText), { + commitHeld + }) + pty = applyToPty(pty, buildTerminalLiveMirrorPayload(mirror)) + sentText = mirror.nextSentText + heldText = mirror.heldText + } + + for (const fieldText of fieldStates) { + step(fieldText, false) + } + step(sentText + heldText, true) + return pty +} + +describe('iOS decomposed (NFD) Hangul live input', () => { + it('Given an NFD field (iOS) When the user types 안녕하세요 Then the PTY receives composed syllables, not jamo', () => { + // Given: Apple text input hands React Native decomposed conjoining jamo, so + // one syllable arrives as 2-3 code points instead of one. + const nfdStates = DUBEOLSIK_FIELD_STATES.map((state) => state.normalize('NFD')) + expect(Array.from(nfdStates.at(-1) ?? '')).toHaveLength(12) + + // When + const pty = replayLiveInput(nfdStates) + + // Then: the mirror holds only the trailing code point, so without + // recomposition every leading jamo is committed on its own (#6995 iOS). + expect(Array.from(pty)).toHaveLength(5) + expect(pty).toBe('안녕하세요') + }) + + it('Given a precomposed NFC field (Android/Gboard) When the same keys are typed Then the PTY result is unchanged', () => { + // Given / When + const pty = replayLiveInput(DUBEOLSIK_FIELD_STATES) + + // Then + expect(pty).toBe('안녕하세요') + }) + + it('Given NFD Latin diacritics When mirrored Then combining marks reach the PTY already composed', () => { + // Given / When: iOS also hands back NFD for Vietnamese/European accents. + const pty = replayLiveInput(['tie', 'tiế', 'tiếng'].map((state) => state.normalize('NFD'))) + + // Then + expect(pty).toBe('tiếng') + expect(Array.from(pty)).toHaveLength(5) + }) +}) diff --git a/mobile/src/terminal/terminal-text-input-normalization.test.ts b/mobile/src/terminal/terminal-text-input-normalization.test.ts index c5213013e31..238c1994edc 100644 --- a/mobile/src/terminal/terminal-text-input-normalization.test.ts +++ b/mobile/src/terminal/terminal-text-input-normalization.test.ts @@ -11,4 +11,29 @@ describe('normalizeTerminalTextInput', () => { it('keeps ASCII hyphens unchanged', () => { expect(normalizeTerminalTextInput('git checkout -- file')).toBe('git checkout -- file') }) + + it('recomposes decomposed Hangul that iOS hands back as conjoining jamo', () => { + const decomposed = '안녕하세요'.normalize('NFD') + expect(Array.from(decomposed)).toHaveLength(12) + + const normalized = normalizeTerminalTextInput(decomposed) + + expect(normalized).toBe('안녕하세요') + expect(Array.from(normalized)).toHaveLength(5) + }) + + it('recomposes decomposed Latin diacritics and stays idempotent on composed text', () => { + expect(normalizeTerminalTextInput('tiếng'.normalize('NFD'))).toBe('tiếng') + expect(normalizeTerminalTextInput('안녕하세요')).toBe('안녕하세요') + expect(normalizeTerminalTextInput(normalizeTerminalTextInput('café'.normalize('NFD')))).toBe( + 'café' + ) + }) + + it('leaves compatibility forms alone so NFC never becomes a lossy NFKC fold', () => { + // NFKC would fold these into different characters and silently rewrite a command. + expect(normalizeTerminalTextInput('ㅇㅏㄴ')).toBe('ㅇㅏㄴ') + expect(normalizeTerminalTextInput('git')).toBe('git') + expect(normalizeTerminalTextInput('①')).toBe('①') + }) }) diff --git a/mobile/src/terminal/terminal-text-input-normalization.ts b/mobile/src/terminal/terminal-text-input-normalization.ts index 6316ce1bbaa..acb40e4fb65 100644 --- a/mobile/src/terminal/terminal-text-input-normalization.ts +++ b/mobile/src/terminal/terminal-text-input-normalization.ts @@ -4,6 +4,11 @@ // value written back into the field, and that write-back kills iOS dictation (#7925). const IOS_SMART_DASH_REPLACEMENT_PATTERN = /[\u2013\u2014]/g +// Why: Apple text input hands back decomposed (NFD) text, so one Hangul syllable +// arrives as 2-3 conjoining jamo. The live mirror holds only the trailing code +// point, so every leading jamo would commit on its own (#6995 iOS). NFC — never +// NFKC, which rewrites compatibility jamo and full-width forms — recomposes them +// before the PTY sees anything; it is a no-op for already-composed input. export function normalizeTerminalTextInput(text: string): string { - return text.replace(IOS_SMART_DASH_REPLACEMENT_PATTERN, '--') + return text.normalize('NFC').replace(IOS_SMART_DASH_REPLACEMENT_PATTERN, '--') }