fix(terminal): stop the macOS IME forwarder from running on iPadOS

Korean typed into a terminal from an iPad web client arrives as loose
jamo instead of composed syllables.

The native-text forwarder is a macOS workaround: it claims a printable
keydown and delivers the input method's substituted text from the input
event alone. It stands aside for IME composition by checking isComposing
and compositionstart. Touch iOS/iPadOS is unreliable about firing those
for hardware-keyboard CJK input, so each jamo keydown is claimed as its
own one-shot substitution rather than deferred to xterm's composition
handling.

It runs there at all because every iOS user agent contains "Mac" -
"Macintosh" in iPad desktop mode, the default since iPadOS 13, and
"like Mac OS X" in mobile mode. maxTouchPoints is the only signal a
real Mac never sets. The Linux branch immediately below already makes
the mirror-image exclusion for Android and CrOS; the Mac branch never
got the same treatment.

Gate the forwarder install only. isMac keeps its other five consumers -
the Ctrl+C interrupt, clipboard bypass, JIS yen input and the standalone
229 keydown policy - which intentionally still treat an iPad with a
hardware keyboard like macOS, matching iPadOS shortcut conventions.

Without the forwarder, xterm's own composition path plus the deferred
textarea diff is the sole delivery mechanism, which is already the
arrangement on Linux.

Not verified on hardware: the claim that composition events are absent
on iPadOS is the mechanism the code supports and the only one matching
the reported symptom, but no on-device capture confirms it. The platform
detection stands on its own regardless.

terminal-ime-input-context-refresh.ts has the same user-agent collision
for its NSTextInputContext refresh. Narrower trigger surface, left for
a follow-up.

Refs #13345
This commit is contained in:
Neil
2026-08-17 19:23:44 -07:00
parent 13b10e0b54
commit b9e59c8537
2 changed files with 52 additions and 12 deletions
@@ -5,6 +5,7 @@ import {
clearQueuedInitialCwdAfterFirstPane,
getPreviousVisibleForTerminalPane,
isTerminalPaneVisibilityResume,
isTouchIOSUserAgent,
mapRestoredPaneTitlesByPaneId,
resolvePaneLinkCwd,
resolvePaneSeedCwd,
@@ -549,3 +550,33 @@ describe('terminal pane visibility resume tracking', () => {
expect(isTerminalPaneVisibilityResume({ previousIsVisible: false, isVisible: true })).toBe(true)
})
})
describe('isTouchIOSUserAgent', () => {
it('is false for a real Mac (Macintosh UA, no touch points)', () => {
expect(
isTouchIOSUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15', 0)
).toBe(false)
})
it('is true for iPadOS desktop-mode Safari (Macintosh UA plus touch points)', () => {
expect(
isTouchIOSUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15', 5)
).toBe(true)
})
it('is true for iPhone Safari ("like Mac OS X" UA plus touch points)', () => {
expect(
isTouchIOSUserAgent(
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15',
5
)
).toBe(true)
})
it('is false for non-Mac UAs regardless of touch points', () => {
expect(isTouchIOSUserAgent('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36', 5)).toBe(false)
expect(
isTouchIOSUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 5)
).toBe(false)
})
})
@@ -451,6 +451,11 @@ export function resolvePaneSeedCwd(splitPaneCwd: string | undefined, fallbackCwd
return splitPaneCwd ?? fallbackCwd
}
/** iPadOS reports a desktop "Macintosh" UA and iOS reports "like Mac OS X" — maxTouchPoints is the only signal a real Mac never sets. */
export function isTouchIOSUserAgent(userAgent: string, maxTouchPoints: number): boolean {
return userAgent.includes('Mac') && maxTouchPoints > 0
}
type SplitStartupPayload = { command: string; env?: Record<string, string> }
type SplitWithStartupDeps = {
@@ -995,6 +1000,8 @@ export function useTerminalPaneLifecycle({
!isMac &&
navigator.userAgent.includes('Linux') &&
!/Android|CrOS/.test(navigator.userAgent)
// Why: gates the forwarder only — isMac stays as-is for the Ctrl+C, clipboard, JIS-yen and 229 policies.
const isTouchIOS = isTouchIOSUserAgent(navigator.userAgent, navigator.maxTouchPoints)
const linuxImeCandidateState = isLinux
? installTerminalImeLinuxCandidateState(pane.terminal.element)
: null
@@ -1006,18 +1013,20 @@ export function useTerminalPaneLifecycle({
}
})
// Why: macOS commits an input source's substituted text through the input event alone, so printable keydowns must not reach xterm's encoder.
const imeNativeTextForwarder = isMac
? installTerminalImeNativeTextForwarder({
terminalElement: pane.terminal.element,
isComposing: () => imeCompositionTracker.isActive(),
sendInput: (data) => pane.terminal.input(data),
getKittyKeyboardFlags: () =>
paneKittyKeyboardModesRef.current.get(pane.id)?.flags ?? 0
})
: {
claimKeyEvent: () => false,
dispose: () => undefined
}
// Not on touch iOS/iPadOS: the forwarder stands aside for IME input via composition events, which iPad Hangul appears not to fire (#13345).
const imeNativeTextForwarder =
isMac && !isTouchIOS
? installTerminalImeNativeTextForwarder({
terminalElement: pane.terminal.element,
isComposing: () => imeCompositionTracker.isActive(),
sendInput: (data) => pane.terminal.input(data),
getKittyKeyboardFlags: () =>
paneKittyKeyboardModesRef.current.get(pane.id)?.flags ?? 0
})
: {
claimKeyEvent: () => false,
dispose: () => undefined
}
imeNativeTextForwarderDisposablesRef.current.set(pane.id, imeNativeTextForwarder)
pane.terminal.attachCustomKeyEventHandler((e) => {
const linuxCandidateClassification = linuxImeCandidateState?.classifyKeyboardEvent(e) ?? {