From 08b215fa80cea08b842cf2d3b680acc373c78057 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:01:53 -0700 Subject: [PATCH] fix(dashboard-popout): forward macOS IME native-text commits in the preview terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../AgentTerminalPreview.test.tsx | 85 +++++++++++++++++++ .../dashboard-popout/AgentTerminalPreview.tsx | 41 +++++++++ 2 files changed, 126 insertions(+) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx index e1a26257374..f2c7d0c61f0 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx @@ -13,6 +13,7 @@ const terminalHarness = vi.hoisted(() => ({ resize: ReturnType reset: ReturnType paste: ReturnType + input: ReturnType 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 + dispose: ReturnType + }[], + trackers: [] as { dispose: ReturnType }[], + 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() + 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() + 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() + 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() await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx index 89efae286f9..72466d242f2 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx @@ -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 | 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() }