diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts index bc0582ca9fe..54e87d693bf 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index bf937b28d36..41ff4202534 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -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 } 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) ?? {