mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
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:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user