From f621c26678da13632640164f557d355fffcaa52a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:19:53 -0700 Subject: [PATCH] fix(mobile): report composing state from accessory backspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The accessory path edits the field itself and then mirrors it, but called applyLiveInputMirror with two arguments where the signature takes three. The local option type declared it 2-ary, so the type checker never saw the drop. An omitted composing flag is not "not composing": it selects the Android-only heuristic that holds the trailing non-ASCII run. A pinyin preedit is plain ASCII, so the heuristic reads it as committed text and sends it. Typing `ni hao`, tapping accessory Backspace, then picking a candidate put `ni ha` on the PTY before the commit, giving `ni ha你好`. Korean survived this by accident - the non-ASCII heuristic re-derives the correct hold for Hangul - which is why it went unnoticed. The held range is the fact the mirror needs, and it is already in scope. Refs #13345 --- ...l-live-accessory-preedit-composing.test.ts | 36 +++++++++++++++++++ ...rminal-live-accessory-input-commit.test.ts | 6 ++-- ...se-terminal-live-accessory-input-commit.ts | 7 ++-- 3 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 mobile/src/terminal/terminal-live-accessory-preedit-composing.test.ts diff --git a/mobile/src/terminal/terminal-live-accessory-preedit-composing.test.ts b/mobile/src/terminal/terminal-live-accessory-preedit-composing.test.ts new file mode 100644 index 00000000000..f7048aa5e09 --- /dev/null +++ b/mobile/src/terminal/terminal-live-accessory-preedit-composing.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { computeTerminalLiveMirrorStep } from './terminal-live-preedit-mirror' +import { getTerminalLiveAccessoryLocalEditText } from './terminal-live-text-commit' + +/** + * Accessory Backspace edits the field itself, so it must report whether a preedit + * is still open. Omitting that report falls back to an Android-only code-point + * heuristic that reads a pinyin preedit — plain ASCII — as committed text. + */ +describe('accessory backspace while composing', () => { + const held = 'ni hao' + + it('holds an ASCII preedit when composing is reported', () => { + const edited = getTerminalLiveAccessoryLocalEditText({ + localEdit: 'backspace', + fieldText: held + }) + const step = computeTerminalLiveMirrorStep('', edited, { + commitHeld: false, + composing: true + }) + expect(step.appendText).toBe('') + expect(step.nextSentText).toBe('') + expect(step.heldText).toBe('ni ha') + }) + + it('leaks the same preedit to the PTY when the report is dropped', () => { + const step = computeTerminalLiveMirrorStep('', 'ni ha', { + commitHeld: false, + composing: undefined + }) + // Pins the failure the call site must avoid: this is what omitting the + // third argument produced, and the candidate commit would append after it. + expect(step.appendText).toBe('ni ha') + }) +}) diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts index 2542b315a8f..4bcd1f4d4d0 100644 --- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts @@ -190,7 +190,8 @@ describe('terminal live accessory input commit hook', () => { const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' }) // Then - expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '') + // composing: a held syllable is still a preedit, so the mirror must not commit it. + expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '', true) expect(result).toEqual({ kind: 'handled' }) expect(harness.sent).toEqual([]) }) @@ -207,7 +208,8 @@ describe('terminal live accessory input commit hook', () => { const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' }) // Then - expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a') + // not composing: nothing is held, so the shortened field is committed text and the diff DELs. + expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a', false) expect(result).toEqual({ kind: 'handled' }) }) }) diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts index 0893ae16098..d38777efef8 100644 --- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts @@ -21,7 +21,7 @@ export async function getTerminalLiveAccessoryInactiveInputCommitResult( type TerminalLiveAccessoryInputCommitOptions = { readonly activeHandle: string | null - readonly applyLiveInputMirror: (handle: string, fieldText: string) => void + readonly applyLiveInputMirror: (handle: string, fieldText: string, composing?: boolean) => void readonly clearPendingLiveInputCommit: () => void readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise readonly heldLiveInputTextRef: RefObject @@ -81,7 +81,10 @@ export function useTerminalLiveAccessoryInputCommit({ // field is edited here and the mirror diff syncs the PTY echo. setLiveInputCapture(editedText) liveInputRef.current?.setNativeProps({ text: editedText }) - applyLiveInputMirror(activeHandle, editedText) + // Why pass it: omitted, the mirror falls back to an Android-only + // code-point heuristic and commits a still-composing preedit — pinyin + // preedit is plain ASCII, so only the held range knows it is unfinished. + applyLiveInputMirror(activeHandle, editedText, heldText.length > 0) return { kind: 'handled' } } case 'commit-held-then-send':