fix(mobile): report composing state from accessory backspace

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
This commit is contained in:
Neil
2026-08-27 02:19:53 -07:00
parent 026389a3bc
commit f621c26678
3 changed files with 45 additions and 4 deletions
@@ -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')
})
})
@@ -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' })
})
})
@@ -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<boolean>
readonly heldLiveInputTextRef: RefObject<string>
@@ -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':