Separate the terminal IME platform checks

The candidate-key guards and the standalone 229 policy were reading one
flag, so tightening either one moved both. Derive them together instead.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-01 22:15:24 -07:00
co-authored by Orca
parent fb11d67d9f
commit 5f799cd4fa
5 changed files with 121 additions and 12 deletions
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { resolveTerminalImePlatform } from './terminal-ime-platform'
const DESKTOP_LINUX =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
const CHROME_OS =
'Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
const ANDROID =
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36'
const MACOS =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
const WINDOWS =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
describe('resolveTerminalImePlatform', () => {
it('treats desktop Linux as both Chromium-family and ibus/fcitx-bearing', () => {
expect(resolveTerminalImePlatform(DESKTOP_LINUX)).toEqual({
isMac: false,
isLinux: true,
isDesktopLinux: true
})
})
// The split exists for these two: their UA says Linux, so they share the 229 behavior,
// but neither runs the input methods the candidate-key guards are written against.
it.each([
['ChromeOS', CHROME_OS],
['Android', ANDROID]
])('keeps %s in the Chromium family but out of the desktop guards', (_name, userAgent) => {
expect(resolveTerminalImePlatform(userAgent)).toEqual({
isMac: false,
isLinux: true,
isDesktopLinux: false
})
})
it.each([
['macOS', MACOS, true],
['Windows', WINDOWS, false]
])('reports %s as neither Linux flavour', (_name, userAgent, isMac) => {
expect(resolveTerminalImePlatform(userAgent)).toEqual({
isMac,
isLinux: false,
isDesktopLinux: false
})
})
})
@@ -0,0 +1,31 @@
export type TerminalImePlatform = {
isMac: boolean
/** Chromium on a Linux kernel — ChromeOS and Android included. */
isLinux: boolean
/** Desktop Linux only — the platforms actually running ibus or fcitx. */
isDesktopLinux: boolean
}
// ChromeOS reports `X11; CrOS x86_64`, with no "Linux" anywhere in it, so a plain
// substring test puts it on the Windows side of every policy below.
const LINUX_KERNEL_PLATFORM = /Linux|CrOS/
const NON_DESKTOP_LINUX_PLATFORM = /Android|CrOS/
/**
* Splits the two platform questions the terminal's IME policy asks, which are not the
* same question and had been sharing one answer.
*
* Standalone keydown 229 is Chromium-family behavior, so ChromeOS and Android belong
* with desktop Linux. The candidate-key guards read ibus/fcitx specifics that neither
* shares, so they are scoped tighter. Deriving both here keeps one user agent from
* getting two different platform verdicts in two different files.
*/
export function resolveTerminalImePlatform(userAgent: string): TerminalImePlatform {
const isMac = userAgent.includes('Mac')
const isLinux = !isMac && LINUX_KERNEL_PLATFORM.test(userAgent)
return {
isMac,
isLinux,
isDesktopLinux: isLinux && !NON_DESKTOP_LINUX_PLATFORM.test(userAgent)
}
}
@@ -79,6 +79,7 @@ import { parseOsc7 } from './parse-osc7'
import { guardParserHandler } from './terminal-parser-handler-guard'
import { resolveTerminalJisYenInput } from './terminal-jis-yen-input'
import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker'
import { resolveTerminalImePlatform } from './terminal-ime-platform'
import { installTerminalImeLinuxCandidateState } from './terminal-ime-linux-candidate-state'
import {
armTerminalImePendingCandidateKeyRelease,
@@ -887,13 +888,8 @@ export function useTerminalPaneLifecycle({
let pendingTerminalInterruptKeyup = false
const pendingTerminalImeCandidateKeyReleases =
createTerminalImePendingCandidateKeyReleases()
const isMac = navigator.userAgent.includes('Mac')
// Why: Android/ChromeOS UAs also contain "Linux"; scope the fcitx candidate-key policy to desktop Linux.
const isLinux =
!isMac &&
navigator.userAgent.includes('Linux') &&
!/Android|CrOS/.test(navigator.userAgent)
const linuxImeCandidateState = isLinux
const { isMac, isLinux, isDesktopLinux } = resolveTerminalImePlatform(navigator.userAgent)
const linuxImeCandidateState = isDesktopLinux
? installTerminalImeLinuxCandidateState(pane.terminal.element)
: null
const macNativeTextInputSourceTracker = isMac ? getMacNativeTextInputSourceTracker() : null
@@ -943,7 +939,8 @@ export function useTerminalPaneLifecycle({
linuxOrphanCandidateDigitGuardActive:
linuxCandidateClassification.candidateDigitGuardActive,
isMac,
isLinux
isLinux,
isDesktopLinux
}
if (shouldSuppressTerminalImeKeyboardEvent(e, imeKeyboardOptions)) {
// Why: clear before arm so a fresh keydown drops any stale pending release before arming its own.
@@ -215,6 +215,32 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => {
).toBe(false)
})
// A Chromebook's UA contains "Linux", so it is the same Chromium/229 platform, but it
// is not running ibus/fcitx. The two policies need different answers for it.
const chromeOsIdle = { ...linuxIdle, isDesktopLinux: false }
it('lets standalone ChromeOS 229 keydowns reach xterm, as on desktop Linux', () => {
expect(
shouldSuppressTerminalImeKeyboardEvent(
event({ key: 'Process', code: 'KeyN', keyCode: 229 }),
chromeOsIdle
)
).toBe(false)
})
it('does not apply the fcitx candidate-key guards on ChromeOS', () => {
const guarded = { ...chromeOsIdle, candidateKeyGuardActive: true }
expect(
shouldPreventDefaultTerminalImeCandidateKey(event({ key: '1', code: 'Digit1' }), guarded)
).toBe(false)
expect(
shouldPreventDefaultTerminalImeCandidateKey(event({ key: '1', code: 'Digit1' }), {
...guarded,
isDesktopLinux: true
})
).toBe(true)
})
it('suppresses Linux 229 keydowns while the composition tracker is active', () => {
expect(
shouldSuppressTerminalImeKeyboardEvent(
@@ -54,7 +54,12 @@ export type XtermImeKeyboardOptions = {
// Required Linux/Windows split: Linux passes standalone 229 keydowns like
// macOS; the Windows-only suppression guards its preedit-diff race (preedit
// can hit the textarea before compositionstart and be flushed by the diff).
// Chromium-family, so ChromeOS and Android count — they share that behavior.
isLinux: boolean
/** Desktop Linux only. The candidate-key guards read ibus/fcitx behavior that
* a Chromebook's IME does not share, so they are scoped tighter than the 229
* policy above. Defaults to `isLinux` for callers on a known desktop. */
isDesktopLinux?: boolean
}
export const TERMINAL_INTERRUPT_INPUT = '\x03'
@@ -114,12 +119,15 @@ export function shouldSuppressTerminalImeKeyboardEvent(
pendingCandidateKeyReleaseActive,
linuxOrphanCandidateDigitGuardActive = false,
isMac,
isLinux
isLinux,
isDesktopLinux = isLinux
} = options
const suppressOrphanCandidateDigit =
isLinux && linuxOrphanCandidateDigitGuardActive && isTerminalImeCandidateDigitKeyEvent(event)
isDesktopLinux &&
linuxOrphanCandidateDigitGuardActive &&
isTerminalImeCandidateDigitKeyEvent(event)
const suppressCandidateKey =
isLinux &&
isDesktopLinux &&
(pendingCandidateKeyReleaseActive ||
(candidateKeyGuardActive && isTerminalImeCandidateSelectionKeyEvent(event)) ||
suppressOrphanCandidateDigit)
@@ -162,7 +170,7 @@ export function shouldPreventDefaultTerminalImeCandidateKey(
// the leaked selector to the PTY.
return (
event.type === 'keydown' &&
options.isLinux &&
(options.isDesktopLinux ?? options.isLinux) &&
((options.candidateKeyGuardActive && isTerminalImeCandidateSelectionKeyEvent(event)) ||
(options.linuxOrphanCandidateDigitGuardActive === true &&
isTerminalImeCandidateDigitKeyEvent(event)))