fix(dashboard-popout): forward macOS IME native-text commits in the preview terminal

The preview terminal enables xterm's kitty keyboard protocol (via
buildDefaultTerminalOptions), whose encoder can encode and cancel a
printable keydown before Chromium commits the real IME/native text —
silently dropping macOS input-source commits and synthetic Unicode
injection. Main-window panes guard this with the IME native-text
forwarder; the pop-out preview never installed it.

Install the composition tracker + forwarder (macOS-only, mirroring
TerminalPane) and claim native-text key events at the top of the
preview's custom key handler so the committed glyph reaches the PTY via
terminal.input() and the existing user-input routing.
This commit is contained in:
Brennan Benson
2026-07-21 13:01:53 -07:00
parent 55b50f813e
commit 08b215fa80
2 changed files with 126 additions and 0 deletions
@@ -13,6 +13,7 @@ const terminalHarness = vi.hoisted(() => ({
resize: ReturnType<typeof vi.fn>
reset: ReturnType<typeof vi.fn>
paste: ReturnType<typeof vi.fn>
input: ReturnType<typeof vi.fn>
selectionText: string
customKeyHandler: ((event: KeyboardEvent) => boolean) | null
}[],
@@ -20,6 +21,17 @@ const terminalHarness = vi.hoisted(() => ({
userInputDispose: vi.fn()
}))
const platformState = vi.hoisted(() => ({ value: 'linux' }))
const imeHarness = vi.hoisted(() => ({
forwarders: [] as {
claimKeyEvent: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
}[],
trackers: [] as { dispose: ReturnType<typeof vi.fn> }[],
claimResult: false
}))
vi.mock('@xterm/xterm', () => ({
Terminal: class {
rows = 24
@@ -39,6 +51,8 @@ vi.mock('@xterm/xterm', () => ({
resize = vi.fn()
reset = vi.fn()
paste = vi.fn()
input = vi.fn()
element = document.createElement('div')
getSelection = vi.fn(() => this.selectionText)
attachCustomKeyEventHandler = vi.fn((handler: (event: KeyboardEvent) => boolean) => {
this.customKeyHandler = handler
@@ -65,6 +79,29 @@ vi.mock('@/components/terminal-pane/terminal-user-input-signal', () => ({
vi.mock('@/components/terminal-pane/use-system-prefers-dark', () => ({
useSystemPrefersDark: () => false
}))
vi.mock('@/lib/shortcut-platform', () => ({
getShortcutPlatform: () => platformState.value
}))
vi.mock('@/components/terminal-pane/terminal-ime-native-text-forwarder', () => ({
installTerminalImeNativeTextForwarder: () => {
const forwarder = {
claimKeyEvent: vi.fn(() => imeHarness.claimResult),
dispose: vi.fn()
}
imeHarness.forwarders.push(forwarder)
return forwarder
}
}))
vi.mock('@/components/terminal-pane/terminal-ime-composition-tracker', () => ({
installTerminalImeCompositionTracker: () => {
const tracker = { isActive: () => false, dispose: vi.fn() }
imeHarness.trackers.push(tracker)
return tracker
}
}))
vi.mock('@/components/terminal-pane/terminal-ime-input-source', () => ({
getMacNativeTextInputSourceTracker: () => ({ getFeatures: () => ({}) })
}))
vi.mock('@/store', () => {
const state = { settings: null, keybindings: {} }
const useAppStore = (selector: (s: typeof state) => unknown): unknown => selector(state)
@@ -87,6 +124,10 @@ describe('AgentTerminalPreview', () => {
beforeEach(() => {
terminalHarness.instances.length = 0
terminalHarness.userInputListener = null
platformState.value = 'linux'
imeHarness.forwarders.length = 0
imeHarness.trackers.length = 0
imeHarness.claimResult = false
emitData = null
emitAppMenuPaste = null
connect.mockResolvedValue({
@@ -146,6 +187,50 @@ describe('AgentTerminalPreview', () => {
expect(ack).toHaveBeenCalledWith('pty-1', 4)
})
it('installs the macOS IME native-text forwarder and lets its claims bypass chord handling', async () => {
platformState.value = 'darwin'
render(<AgentTerminalPreview ptyId="pty-1" />)
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
const terminal = terminalHarness.instances[0]!
await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
expect(imeHarness.forwarders).toHaveLength(1)
expect(imeHarness.trackers).toHaveLength(1)
// A claimed native-text key bypasses xterm AND the clipboard chords.
imeHarness.claimResult = true
terminal.selectionText = 'selected text'
const handled = terminal.customKeyHandler!(
new KeyboardEvent('keydown', { key: 'C', code: 'KeyC', metaKey: true, shiftKey: true })
)
expect(handled).toBe(false)
expect(writeClipboardText).not.toHaveBeenCalled()
// Unclaimed events still reach the chord handling.
imeHarness.claimResult = false
const copied = terminal.customKeyHandler!(
new KeyboardEvent('keydown', { key: 'C', code: 'KeyC', metaKey: true, shiftKey: true })
)
expect(copied).toBe(false)
expect(writeClipboardText).toHaveBeenCalledWith('selected text')
})
it('does not install the IME native-text forwarder off macOS', async () => {
render(<AgentTerminalPreview ptyId="pty-1" />)
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
await waitFor(() => expect(terminalHarness.instances[0]!.customKeyHandler).not.toBeNull())
expect(imeHarness.forwarders).toHaveLength(0)
expect(imeHarness.trackers).toHaveLength(0)
})
it('disposes the IME bridge on unmount', async () => {
platformState.value = 'darwin'
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
await waitFor(() => expect(imeHarness.forwarders).toHaveLength(1))
view.unmount()
expect(imeHarness.forwarders[0]!.dispose).toHaveBeenCalledTimes(1)
expect(imeHarness.trackers[0]!.dispose).toHaveBeenCalledTimes(1)
})
it('copies the terminal selection on the copy chord and blocks xterm handling', async () => {
render(<AgentTerminalPreview ptyId="pty-1" />)
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
@@ -5,6 +5,15 @@ import { buildDefaultTerminalOptions } from '@/lib/pane-manager/pane-terminal-op
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { subscribeToTerminalUserInput } from '@/components/terminal-pane/terminal-user-input-signal'
import { TERMINAL_PASTE_MAX_BYTES } from '@/components/terminal-pane/terminal-paste-limits'
import {
installTerminalImeCompositionTracker,
type TerminalImeCompositionTracker
} from '@/components/terminal-pane/terminal-ime-composition-tracker'
import {
installTerminalImeNativeTextForwarder,
type TerminalImeNativeTextForwarder
} from '@/components/terminal-pane/terminal-ime-native-text-forwarder'
import { getMacNativeTextInputSourceTracker } from '@/components/terminal-pane/terminal-ime-input-source'
import { composeActiveTerminalTheme } from '@/components/terminal-pane/terminal-appearance'
import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark'
import { translate } from '@/i18n/i18n'
@@ -60,6 +69,8 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
let terminal: Terminal | null = null
let offData: (() => void) | null = null
let userInputDisposable: { dispose: () => void } | null = null
let imeCompositionTracker: TerminalImeCompositionTracker | null = null
let imeNativeTextForwarder: TerminalImeNativeTextForwarder | null = null
let refreshInFlight = false
let refreshAgain = false
let retryTimer: ReturnType<typeof setTimeout> | null = null
@@ -131,12 +142,39 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
}
}
const disposeImeNativeTextBridge = (): void => {
imeNativeTextForwarder?.dispose()
imeNativeTextForwarder = null
imeCompositionTracker?.dispose()
imeCompositionTracker = null
}
// Why: xterm's kitty encoder can encode+cancel a printable keydown before
// Chromium commits IME/native text, silently dropping the glyph (mirrors
// TerminalPane's forwarder; macOS-only like the pane's install).
const installImeNativeTextBridge = (): void => {
if (!terminal || getShortcutPlatform() !== 'darwin') {
return
}
imeCompositionTracker = installTerminalImeCompositionTracker(terminal.element)
imeNativeTextForwarder = installTerminalImeNativeTextForwarder({
terminalElement: terminal.element,
isComposing: () => imeCompositionTracker?.isActive() ?? false,
sendInput: (data) => terminal?.input(data),
getInputSourceFeatures: () => getMacNativeTextInputSourceTracker().getFeatures()
})
}
const installClipboardShortcuts = (): void => {
if (!terminal) {
return
}
const platform = getShortcutPlatform()
terminal.attachCustomKeyEventHandler((event) => {
if (imeNativeTextForwarder?.claimKeyEvent(event)) {
// Why: bypass xterm's kitty encoder for native-text keydowns so the committed glyph survives via the input event.
return false
}
if (event.type !== 'keydown') {
return true
}
@@ -211,6 +249,7 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
return
}
installInputRouting()
installImeNativeTextBridge()
installClipboardShortcuts()
} else if (replaceExisting) {
// Why: keep the old frame visible during capture, then atomically replace it once the authoritative snapshot arrives.
@@ -276,6 +315,7 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
offData = null
userInputDisposable?.dispose()
userInputDisposable = null
disposeImeNativeTextBridge()
terminal?.dispose()
terminal = null
void window.api.terminalPreview.unsubscribe(ptyId)
@@ -320,6 +360,7 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
offAppMenuPaste()
offData?.()
userInputDisposable?.dispose()
disposeImeNativeTextBridge()
void window.api.terminalPreview.unsubscribe(ptyId)
terminal?.dispose()
}