diff --git a/src/main/window/clipboard-dashboard-popout-access.test.ts b/src/main/window/clipboard-dashboard-popout-access.test.ts new file mode 100644 index 00000000000..0466853dbc1 --- /dev/null +++ b/src/main/window/clipboard-dashboard-popout-access.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + handlers, + clipboardReadText, + clipboardWriteText, + clipboardReadImage, + clipboardWriteImage, + clipboardWriteBuffer, + isDashboardPopoutRenderer +} = vi.hoisted(() => ({ + handlers: new Map unknown>(), + clipboardReadText: vi.fn(() => 'terminal clipboard text'), + clipboardWriteText: vi.fn(), + clipboardReadImage: vi.fn(), + clipboardWriteImage: vi.fn(), + clipboardWriteBuffer: vi.fn(), + isDashboardPopoutRenderer: vi.fn(() => true) +})) + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => '/tmp') }, + clipboard: { + readText: clipboardReadText, + readBuffer: vi.fn(), + writeText: clipboardWriteText, + readImage: clipboardReadImage, + writeImage: clipboardWriteImage, + writeBuffer: clipboardWriteBuffer + }, + ipcMain: { + removeHandler: (channel: string) => handlers.delete(channel), + handle: (channel: string, handler: (...args: unknown[]) => unknown) => + handlers.set(channel, handler) + }, + nativeImage: { createFromBuffer: vi.fn() } +})) + +vi.mock('./dashboard-popout-window', () => ({ isDashboardPopoutRenderer })) +vi.mock('./clipboard-remote-file-copy', () => ({ + cleanupExpiredRemoteClipboardFiles: vi.fn(async () => undefined), + writeRemoteFileToClipboard: vi.fn() +})) + +import { + registerClipboardHandlers, + setTrustedClipboardRendererWebContentsId +} from './clipboard-ipc-handlers' + +const popoutEvent = { + sender: { + id: 42, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///popout.html' + } +} + +describe('dashboard popout clipboard access', () => { + beforeEach(() => { + handlers.clear() + vi.clearAllMocks() + isDashboardPopoutRenderer.mockReturnValue(true) + clipboardReadText.mockReturnValue('terminal clipboard text') + setTrustedClipboardRendererWebContentsId(17) + registerClipboardHandlers({} as never) + }) + + it('allows terminal text copy and paste through the exact popout renderer', async () => { + await expect(handlers.get('clipboard:readText')?.(popoutEvent)).resolves.toBe( + 'terminal clipboard text' + ) + await expect( + handlers.get('clipboard:writeText')?.(popoutEvent, 'terminal selection') + ).resolves.toBeUndefined() + + expect(clipboardWriteText).toHaveBeenCalledWith('terminal selection') + }) + + it('does not extend popout authority to selection, image, file, or remote clipboard APIs', async () => { + await expect(handlers.get('clipboard:readSelectionText')?.(popoutEvent)).rejects.toThrow( + 'Unauthorized clipboard IPC sender' + ) + await expect( + handlers.get('clipboard:writeSelectionText')?.(popoutEvent, 'primary selection') + ).rejects.toThrow('Unauthorized clipboard IPC sender') + await expect(handlers.get('clipboard:saveImageAsTempFile')?.(popoutEvent)).rejects.toThrow( + 'Unauthorized clipboard IPC sender' + ) + expect(() => + handlers.get('clipboard:writeFile')?.(popoutEvent, { + filePath: '/tmp/copied-file.txt', + connectionId: 'ssh-secret' + }) + ).toThrow('Unauthorized clipboard IPC sender') + expect(() => + handlers.get('clipboard:writeImage')?.(popoutEvent, 'data:image/png;base64,AAAA') + ).toThrow('Unauthorized clipboard IPC sender') + + expect(clipboardReadImage).not.toHaveBeenCalled() + expect(clipboardWriteImage).not.toHaveBeenCalled() + expect(clipboardWriteBuffer).not.toHaveBeenCalled() + }) + + it('still rejects unrelated renderer windows from text clipboard APIs', async () => { + isDashboardPopoutRenderer.mockReturnValue(false) + + await expect(handlers.get('clipboard:readText')?.(popoutEvent)).rejects.toThrow( + 'Unauthorized clipboard IPC sender' + ) + await expect(handlers.get('clipboard:writeText')?.(popoutEvent, 'secret')).rejects.toThrow( + 'Unauthorized clipboard IPC sender' + ) + + expect(clipboardReadText).not.toHaveBeenCalled() + expect(clipboardWriteText).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index 1e0c32b3d62..b1465beb82d 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -127,6 +127,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ vi.mock('../ipc/runtime-environment-transport-routing', () => ({ callRuntimeEnvironment: callRuntimeEnvironmentMock })) +vi.mock('./dashboard-popout-window', () => ({ isDashboardPopoutRenderer: () => false })) import { registerClipboardHandlers, diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index 47b4d5d47ff..f519da7ed4b 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -35,6 +35,7 @@ import { } from './clipboard-remote-file-copy' import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload' import { readWindowsClipboardImageFileAsPng } from './clipboard-windows-image-file' +import { isDashboardPopoutRenderer } from './dashboard-popout-window' let trustedClipboardRendererWebContentsId: number | null = null @@ -84,7 +85,7 @@ export function registerClipboardHandlers(store: Store): void { void cleanupExpiredRemoteClipboardFiles() ipcMain.handle('clipboard:readText', async (event, options?: ReadClipboardTextOptions) => { - assertTrustedClipboardSender(event) + assertTrustedClipboardTextSender(event) return assertClipboardTextWithinLimitWithYield(clipboard.readText(), options) }) ipcMain.handle( @@ -155,7 +156,7 @@ export function registerClipboardHandlers(store: Store): void { } ) ipcMain.handle('clipboard:writeText', async (event, text: string) => { - assertTrustedClipboardSender(event) + assertTrustedClipboardTextSender(event) return clipboard.writeText(await assertClipboardTextWriteWithinLimitWithYield(text)) }) ipcMain.handle('clipboard:writeSelectionText', async (event, text: string) => { @@ -239,6 +240,14 @@ function assertTrustedClipboardSender(event: IpcMainInvokeEvent): void { } } +function assertTrustedClipboardTextSender(event: IpcMainInvokeEvent): void { + // Why: terminal copy/paste runs in the exact dashboard popout window, but its + // clipboard authority must not extend to image, file, or remote operations. + if (!isTrustedClipboardRenderer(event.sender) && !isDashboardPopoutRenderer(event.sender)) { + throw new Error('Unauthorized clipboard IPC sender') + } +} + function isTrustedClipboardRenderer(sender: WebContents): boolean { if (sender.isDestroyed() || sender.getType() !== 'window') { return false diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx index 4102e0fe59e..fe1e9caec8d 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx @@ -3,6 +3,14 @@ import '@testing-library/jest-dom/vitest' import { act, cleanup, render, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + TERMINAL_PASTE_CHUNK_MAX_BYTES, + TERMINAL_PASTE_DIRECT_MAX_BYTES +} from '@/components/terminal-pane/terminal-paste-limits' +import { + BRACKETED_PASTE_END, + BRACKETED_PASTE_START +} from '@/components/terminal-pane/terminal-bracketed-paste' const terminalHarness = vi.hoisted(() => ({ instances: [] as { @@ -12,17 +20,37 @@ const terminalHarness = vi.hoisted(() => ({ dispose: ReturnType resize: ReturnType reset: ReturnType + paste: ReturnType + input: ReturnType + modes: { bracketedPasteMode: boolean } + selectionText: string + customKeyHandler: ((event: KeyboardEvent) => boolean) | null }[], userInputListener: null as (() => void) | null, userInputDispose: vi.fn() })) +const platformState = vi.hoisted(() => ({ value: 'linux' })) + +const imeHarness = vi.hoisted(() => ({ + forwarders: [] as { + claimKeyEvent: ReturnType + dispose: ReturnType + sendInput: (data: string) => void + }[], + trackers: [] as { dispose: ReturnType }[], + claimResult: false, + inputSourceTrackerRequests: 0 +})) + vi.mock('@xterm/xterm', () => ({ Terminal: class { rows = 24 buffer = { active: { cursorY: 0 } } writeCallbacks: (() => void)[] = [] onDataListener: ((data: string) => void) | null = null + customKeyHandler: ((event: KeyboardEvent) => boolean) | null = null + selectionText = '' write = vi.fn((_data: string, callback?: () => void) => { if (callback) { this.writeCallbacks.push(callback) @@ -33,6 +61,20 @@ vi.mock('@xterm/xterm', () => ({ dispose = vi.fn() resize = vi.fn() reset = vi.fn() + modes = { bracketedPasteMode: false } + paste = vi.fn((data: string) => { + terminalHarness.userInputListener?.() + this.onDataListener?.(data) + }) + input = vi.fn((data: string) => { + terminalHarness.userInputListener?.() + this.onDataListener?.(data) + }) + element = document.createElement('div') + getSelection = vi.fn(() => this.selectionText) + attachCustomKeyEventHandler = vi.fn((handler: (event: KeyboardEvent) => boolean) => { + this.customKeyHandler = handler + }) onData = vi.fn((listener: (data: string) => void) => { this.onDataListener = listener return { dispose: vi.fn() } @@ -55,27 +97,67 @@ vi.mock('@/components/terminal-pane/terminal-user-input-signal', () => ({ vi.mock('@/components/terminal-pane/use-system-prefers-dark', () => ({ useSystemPrefersDark: () => false })) -vi.mock('@/store', () => ({ - useAppStore: (selector: (state: { settings: null }) => unknown) => selector({ settings: null }) +vi.mock('@/lib/shortcut-platform', () => ({ + getShortcutPlatform: () => platformState.value })) +vi.mock('@/components/terminal-pane/terminal-ime-native-text-forwarder', () => ({ + installTerminalImeNativeTextForwarder: (args: { sendInput: (data: string) => void }) => { + const forwarder = { + claimKeyEvent: vi.fn(() => imeHarness.claimResult), + dispose: vi.fn(), + sendInput: args.sendInput + } + 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: () => { + imeHarness.inputSourceTrackerRequests++ + return { getFeatures: () => ({}) } + } +})) +vi.mock('@/store', () => { + const state = { settings: null, keybindings: {} } + const useAppStore = (selector: (s: typeof state) => unknown): unknown => selector(state) + useAppStore.getState = (): typeof state => state + return { useAppStore } +}) import { AgentTerminalPreview } from './AgentTerminalPreview' describe('AgentTerminalPreview', () => { - const input = vi.fn(async () => true) + const input = vi.fn(async (_ptyId: string, _data: string) => true) const ack = vi.fn(async () => {}) const unsubscribe = vi.fn(async () => {}) const connect = vi.fn() + const readClipboardText = vi.fn(async () => 'clip-text') + const writeClipboardText = vi.fn(async () => {}) let emitData: ((payload: unknown) => void) | null + let emitAppMenuPaste: (() => void) | null beforeEach(() => { terminalHarness.instances.length = 0 terminalHarness.userInputListener = null + platformState.value = 'linux' + imeHarness.forwarders.length = 0 + imeHarness.trackers.length = 0 + imeHarness.claimResult = false + imeHarness.inputSourceTrackerRequests = 0 emitData = null + emitAppMenuPaste = null connect.mockResolvedValue({ snapshot: { data: '', cols: 80, rows: 24, seq: 1 }, replay: [] }) + readClipboardText.mockResolvedValue('clip-text') Object.assign(window, { api: { terminalPreview: { @@ -87,6 +169,14 @@ describe('AgentTerminalPreview', () => { emitData = listener return vi.fn() } + }, + ui: { + readClipboardText, + writeClipboardText, + onAppMenuPaste: (listener: () => void) => { + emitAppMenuPaste = listener + return vi.fn() + } } } }) @@ -120,6 +210,265 @@ 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) + expect(imeHarness.inputSourceTrackerRequests).toBe(1) + + imeHarness.forwarders[0]!.sendInput('。') + expect(terminal.input).toHaveBeenCalledOnce() + expect(input).toHaveBeenCalledOnce() + expect(input).toHaveBeenCalledWith('pty-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') + expect(imeHarness.inputSourceTrackerRequests).toBe(1) + }) + + 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('disposes the IME bridge once when the PTY disappears', async () => { + platformState.value = 'darwin' + connect.mockResolvedValueOnce({ + snapshot: { data: '', cols: 80, rows: 24, seq: 1 }, + replay: [] + }) + connect.mockResolvedValueOnce({ snapshot: null, replay: [] }) + const view = render() + await waitFor(() => expect(imeHarness.forwarders).toHaveLength(1)) + + act(() => emitData?.({ type: 'resync', ptyId: 'pty-1' })) + await waitFor(() => expect(imeHarness.forwarders[0]!.dispose).toHaveBeenCalledOnce()) + expect(imeHarness.trackers[0]!.dispose).toHaveBeenCalledOnce() + + view.unmount() + expect(imeHarness.forwarders[0]!.dispose).toHaveBeenCalledOnce() + expect(imeHarness.trackers[0]!.dispose).toHaveBeenCalledOnce() + }) + + it('copies the terminal selection on the copy chord and blocks xterm handling', async () => { + render() + await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) + const terminal = terminalHarness.instances[0]! + await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull()) + + terminal.selectionText = 'selected text' + const keydown = new KeyboardEvent('keydown', { + key: 'C', + code: 'KeyC', + ctrlKey: true, + shiftKey: true, + cancelable: true + }) + const handled = terminal.customKeyHandler!(keydown) + const keyupHandled = terminal.customKeyHandler!( + new KeyboardEvent('keyup', { key: 'C', code: 'KeyC', ctrlKey: true, shiftKey: true }) + ) + expect(handled).toBe(false) + expect(keyupHandled).toBe(false) + expect(keydown.defaultPrevented).toBe(true) + expect(writeClipboardText).toHaveBeenCalledWith('selected text') + }) + + it('keeps an empty copy chord from leaking terminal input', async () => { + render() + await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) + const terminal = terminalHarness.instances[0]! + await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull()) + + const handled = terminal.customKeyHandler!( + new KeyboardEvent('keydown', { key: 'C', code: 'KeyC', ctrlKey: true, shiftKey: true }) + ) + expect(handled).toBe(false) + expect(writeClipboardText).not.toHaveBeenCalled() + }) + + it('pastes clipboard text on the app-menu paste signal while the preview owns focus', async () => { + const view = render() + await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) + const terminal = terminalHarness.instances[0]! + expect(emitAppMenuPaste).not.toBeNull() + + const host = view.container.querySelector('.origin-bottom-left')! + const focusTarget = document.createElement('input') + host.appendChild(focusTarget) + focusTarget.focus() + + act(() => emitAppMenuPaste!()) + await waitFor(() => expect(terminal.paste).toHaveBeenCalledWith('clip-text')) + expect(input).toHaveBeenCalledWith('pty-1', 'clip-text') + }) + + it('ignores the app-menu paste signal when focus is outside the preview', async () => { + render() + await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) + expect(emitAppMenuPaste).not.toBeNull() + + await act(async () => emitAppMenuPaste!()) + expect(readClipboardText).not.toHaveBeenCalled() + expect(terminalHarness.instances[0]!.paste).not.toHaveBeenCalled() + }) + + it('leaves plain Ctrl+V to the Edit-menu accelerator but handles the shifted paste chord', async () => { + const view = render() + await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) + const terminal = terminalHarness.instances[0]! + await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull()) + const host = view.container.querySelector('.origin-bottom-left')! + const terminalInput = document.createElement('input') + host.appendChild(terminalInput) + terminalInput.focus() + + const plain = terminal.customKeyHandler!( + new KeyboardEvent('keydown', { key: 'v', code: 'KeyV', ctrlKey: true }) + ) + expect(plain).toBe(true) + expect(readClipboardText).not.toHaveBeenCalled() + + const shiftedEvent = new KeyboardEvent('keydown', { + key: 'V', + code: 'KeyV', + ctrlKey: true, + shiftKey: true, + cancelable: true + }) + const shifted = terminal.customKeyHandler!(shiftedEvent) + const repeated = terminal.customKeyHandler!( + new KeyboardEvent('keydown', { + key: 'V', + code: 'KeyV', + ctrlKey: true, + shiftKey: true, + repeat: true + }) + ) + expect(shifted).toBe(false) + expect(repeated).toBe(false) + expect(shiftedEvent.defaultPrevented).toBe(true) + await waitFor(() => expect(terminal.paste).toHaveBeenCalledWith('clip-text')) + expect(readClipboardText).toHaveBeenCalledTimes(1) + expect( + terminal.customKeyHandler!( + new KeyboardEvent('keyup', { key: 'V', code: 'KeyV', ctrlKey: true, shiftKey: true }) + ) + ).toBe(false) + }) + + it('cancels an async paste when the preview loses focus', async () => { + let resolveClipboard!: (text: string) => void + readClipboardText.mockReturnValueOnce( + new Promise((resolve) => { + resolveClipboard = resolve + }) + ) + const view = render() + await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) + const terminal = terminalHarness.instances[0]! + const host = view.container.querySelector('.origin-bottom-left')! + const terminalInput = document.createElement('input') + const outsideInput = document.createElement('input') + host.appendChild(terminalInput) + view.container.appendChild(outsideInput) + terminalInput.focus() + + act(() => emitAppMenuPaste!()) + outsideInput.focus() + await act(async () => resolveClipboard('stale text')) + + expect(terminal.paste).not.toHaveBeenCalled() + expect(input).not.toHaveBeenCalled() + }) + + it('streams large pastes as bounded IPC payloads instead of one renderer-blocking write', async () => { + const encoder = new TextEncoder() + const multibytePrefix = '😀'.repeat(TERMINAL_PASTE_DIRECT_MAX_BYTES / 4 + 1) + const largePaste = `${multibytePrefix}\r\nnext\n` + const expectedPaste = `${multibytePrefix}\rnext\r` + readClipboardText.mockResolvedValueOnce(largePaste) + const view = render() + await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) + const terminal = terminalHarness.instances[0]! + const host = view.container.querySelector('.origin-bottom-left')! + const terminalInput = document.createElement('input') + host.appendChild(terminalInput) + terminalInput.focus() + + act(() => emitAppMenuPaste!()) + const expectedChunks = Math.ceil( + encoder.encode(expectedPaste).byteLength / TERMINAL_PASTE_CHUNK_MAX_BYTES + ) + await waitFor(() => expect(input).toHaveBeenCalledTimes(expectedChunks)) + + const payloads = input.mock.calls.map(([, data]) => data as string) + expect(terminal.paste).not.toHaveBeenCalled() + expect(payloads.join('')).toBe(expectedPaste) + expect( + payloads.every( + (payload) => encoder.encode(payload).byteLength <= TERMINAL_PASTE_CHUNK_MAX_BYTES + ) + ).toBe(true) + }) + + it('closes a bracketed large paste when focus changes between chunks', async () => { + readClipboardText.mockResolvedValueOnce('x'.repeat(TERMINAL_PASTE_DIRECT_MAX_BYTES + 1)) + const view = render() + await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) + const terminal = terminalHarness.instances[0]! + terminal.modes.bracketedPasteMode = true + const host = view.container.querySelector('.origin-bottom-left')! + const terminalInput = document.createElement('input') + const outsideInput = document.createElement('input') + host.appendChild(terminalInput) + view.container.appendChild(outsideInput) + terminalInput.focus() + input.mockImplementationOnce(async () => { + outsideInput.focus() + return true + }) + + act(() => emitAppMenuPaste!()) + await waitFor(() => expect(input).toHaveBeenCalledTimes(2)) + + expect(input.mock.calls.map(([, data]) => data)).toEqual([ + BRACKETED_PASTE_START, + BRACKETED_PASTE_END + ]) + }) + it('keeps the existing terminal visible while a resync snapshot is captured', async () => { let resolveRefresh!: (value: { snapshot: { data: string; cols: number; rows: number; seq: number } diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx index 129bcb35ce9..166a2cbad38 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx @@ -2,11 +2,28 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Terminal } from '@xterm/xterm' import '@xterm/xterm/css/xterm.css' import { buildDefaultTerminalOptions } from '@/lib/pane-manager/pane-terminal-options' +import { getShortcutPlatform } from '@/lib/shortcut-platform' import { subscribeToTerminalUserInput } from '@/components/terminal-pane/terminal-user-input-signal' +import { + executeTerminalPastePlan, + planTerminalPasteWithYield +} from '@/components/terminal-pane/terminal-paste-coordinator' +import { resolveTerminalPasteRuntime } from '@/components/terminal-pane/terminal-paste-runtime' +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' import { getBuiltinTheme, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' +import { keybindingMatchesAction } from '../../../../shared/keybindings' import { cn } from '@/lib/utils' import { useAppStore } from '@/store' import type { TerminalPreviewDataPayload } from '../../../../shared/terminal-preview' @@ -57,6 +74,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 @@ -115,6 +134,137 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El }) } + const pasteClipboardText = async ( + activeElementAtDispatch: Element | null, + source: 'keyboard' | 'app-menu' + ): Promise => { + let text: string + try { + text = await window.api.ui.readClipboardText({ maxBytes: TERMINAL_PASTE_MAX_BYTES }) + } catch { + return + } + const pasteTerminal = terminal + if (!pasteTerminal || !text) { + return + } + const targetIsCurrent = (): boolean => + !disposed && + terminal === pasteTerminal && + activeElementAtDispatch !== null && + document.activeElement === activeElementAtDispatch && + container.contains(activeElementAtDispatch) + if (!targetIsCurrent()) { + return + } + const platform = getShortcutPlatform() + const plan = await planTerminalPasteWithYield({ + text, + source, + target: { + kind: 'terminal', + paneId: 0, + leafId: ptyId, + ptyId, + runtime: resolveTerminalPasteRuntime({ platform, ptyId }) + }, + terminalBracketedPasteMode: pasteTerminal.modes.bracketedPasteMode + }) + await executeTerminalPastePlan(plan, { + // Why: stream large pastes so the renderer never emits one huge IPC payload. + pasteText: (pasteText) => pasteTerminal.paste(pasteText), + writePty: (data) => window.api.terminalPreview.input(ptyId, data), + isTargetCurrent: targetIsCurrent, + // Why: if focus changes mid-bracketed paste, the closing marker must still reach the live PTY. + canContinue: () => true + }) + } + + 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 + } + // Why: prewarm the async input-source lookup before the first native-text key needs classification. + const inputSourceTracker = getMacNativeTextInputSourceTracker() + imeCompositionTracker = installTerminalImeCompositionTracker(terminal.element) + imeNativeTextForwarder = installTerminalImeNativeTextForwarder({ + terminalElement: terminal.element, + isComposing: () => imeCompositionTracker?.isActive() ?? false, + sendInput: (data) => terminal?.input(data), + getInputSourceFeatures: () => inputSourceTracker.getFeatures() + }) + } + + const installClipboardShortcuts = (): void => { + if (!terminal) { + return + } + const platform = getShortcutPlatform() + const consumedClipboardKeys = new Set() + const consumeEvent = (event: KeyboardEvent): false => { + event.preventDefault() + event.stopPropagation() + return false + } + 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') { + const keyIdentity = event.code || event.key + if (consumedClipboardKeys.has(keyIdentity)) { + if (event.type === 'keyup') { + consumedClipboardKeys.delete(keyIdentity) + } + return consumeEvent(event) + } + return true + } + const keybindings = useAppStore.getState().keybindings + if (keybindingMatchesAction('terminal.copySelection', event, platform, keybindings)) { + const keyIdentity = event.code || event.key + const firstKeydown = !consumedClipboardKeys.has(keyIdentity) + consumedClipboardKeys.add(keyIdentity) + const selection = terminal?.getSelection() + if (firstKeydown && selection) { + void window.api.ui.writeClipboardText(selection).catch(() => undefined) + } + return consumeEvent(event) + } + // Why: plain Mod+V is the Edit-menu accelerator, which reaches this window as ui:appMenuPaste — matching it here too would paste twice. + const isMenuPasteChord = + (platform === 'darwin' + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey) && + !event.altKey && + !event.shiftKey && + event.key.toLowerCase() === 'v' + if ( + !isMenuPasteChord && + keybindingMatchesAction('terminal.paste', event, platform, keybindings) + ) { + const keyIdentity = event.code || event.key + if (!consumedClipboardKeys.has(keyIdentity)) { + consumedClipboardKeys.add(keyIdentity) + void pasteClipboardText(document.activeElement, 'keyboard') + } + return consumeEvent(event) + } + return true + }) + } + const installInputRouting = (): void => { if (!terminal) { return @@ -158,6 +308,8 @@ 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. terminal.resize( @@ -222,6 +374,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) @@ -235,6 +388,16 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El replayConnection(connection, replaceExisting, () => void setup(true)) } + // Why: the popout has no TerminalPane/useAppMenuPaste, so the Edit menu's + // Cmd/Ctrl+V (routed to the focused window as ui:appMenuPaste) would + // otherwise be dropped and paste would silently do nothing here. + const offAppMenuPaste = window.api.ui.onAppMenuPaste(() => { + const active = document.activeElement + if (active && container.contains(active)) { + void pasteClipboardText(active, 'app-menu') + } + }) + offData = window.api.terminalPreview.onData((payload) => { if (payload.ptyId !== ptyId) { return @@ -253,8 +416,10 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El if (retryTimer) { clearTimeout(retryTimer) } + offAppMenuPaste() offData?.() userInputDisposable?.dispose() + disposeImeNativeTextBridge() void window.api.terminalPreview.unsubscribe(ptyId) terminal?.dispose() } diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-chunks.ts b/src/renderer/src/components/terminal-pane/terminal-paste-chunks.ts index dddb91dadb0..394259d3923 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-chunks.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-chunks.ts @@ -1,14 +1,12 @@ -import { - BRACKETED_PASTE_END, - BRACKETED_PASTE_START, - normalizeTerminalPasteLineEndings -} from './terminal-bracketed-paste' +import { BRACKETED_PASTE_END, BRACKETED_PASTE_START } from './terminal-bracketed-paste' import { TERMINAL_PASTE_CHUNK_MAX_BYTES } from './terminal-paste-limits' import type { TerminalPastePlan } from './terminal-paste-coordinator' const TERMINAL_PASTE_ESCAPE_CODE_POINT = 0x1b const TERMINAL_PASTE_INERT_ESCAPE_CODE_POINT = 0x241b const TERMINAL_PASTE_INERT_ESCAPE = '\u241b' +const LINE_FEED_CODE_POINT = 0x0a +const CARRIAGE_RETURN_CODE_POINT = 0x0d export function chunkTerminalPastePlan(plan: TerminalPastePlan): string[] { return [...iterateTerminalPastePlanChunks(plan)] @@ -16,16 +14,15 @@ export function chunkTerminalPastePlan(plan: TerminalPastePlan): string[] { export function* iterateTerminalPastePlanChunks(plan: TerminalPastePlan): Generator { const maxChunkBytes = Math.max(4, plan.maxChunkBytes ?? TERMINAL_PASTE_CHUNK_MAX_BYTES) - // Why: normalize before chunking — a per-chunk pass could split a CRLF pair - // across a boundary and leak the LF half to ConPTY as a submit. - const text = - plan.newlinePolicy === 'terminal-cr' - ? normalizeTerminalPasteLineEndings(plan.payload.plainText) - : plan.payload.plainText if (plan.bracketed) { yield BRACKETED_PASTE_START } - yield* iterateTextByUtf8Bytes(text, maxChunkBytes, plan.bracketed) + yield* iterateTextByUtf8Bytes( + plan.payload.plainText, + maxChunkBytes, + plan.bracketed, + plan.newlinePolicy === 'terminal-cr' + ) if (plan.bracketed) { yield BRACKETED_PASTE_END } @@ -34,19 +31,35 @@ export function* iterateTerminalPastePlanChunks(plan: TerminalPastePlan): Genera function* iterateTextByUtf8Bytes( text: string, maxBytes: number, - sanitizeEscapes: boolean + sanitizeEscapes: boolean, + normalizeLineEndings: boolean ): Generator { let chunk = '' let chunkBytes = 0 for (let index = 0; index < text.length; index += 1) { const codePoint = text.codePointAt(index) ?? 0 const codeUnitLength = codePoint > 0xffff ? 2 : 1 + // Why: iterator normalization avoids a full-size copy and keeps CRLF atomic across chunks. + if ( + normalizeLineEndings && + codePoint === LINE_FEED_CODE_POINT && + index > 0 && + text.charCodeAt(index - 1) === CARRIAGE_RETURN_CODE_POINT + ) { + continue + } + const normalizedCodePoint = + normalizeLineEndings && codePoint === LINE_FEED_CODE_POINT + ? CARRIAGE_RETURN_CODE_POINT + : codePoint const sanitizedEscape = sanitizeEscapes && codePoint === TERMINAL_PASTE_ESCAPE_CODE_POINT const next = sanitizedEscape ? TERMINAL_PASTE_INERT_ESCAPE - : text.slice(index, index + codeUnitLength) + : normalizedCodePoint === codePoint + ? text.slice(index, index + codeUnitLength) + : '\r' const nextBytes = utf8BytesForCodePoint( - sanitizedEscape ? TERMINAL_PASTE_INERT_ESCAPE_CODE_POINT : codePoint + sanitizedEscape ? TERMINAL_PASTE_INERT_ESCAPE_CODE_POINT : normalizedCodePoint ) if (chunk && chunkBytes + nextBytes > maxBytes) { yield chunk diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts b/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts index 7ee54dc5ccc..ca5b211d588 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import { PASTE_PAYLOAD_CORPUS } from '../../lib/paste-payload-corpus' -import { BRACKETED_PASTE_END, BRACKETED_PASTE_START } from './terminal-bracketed-paste' +import { + BRACKETED_PASTE_END, + BRACKETED_PASTE_START, + normalizeTerminalPasteLineEndings +} from './terminal-bracketed-paste' import { createRedactedPasteExecutionDiagnostic } from './terminal-paste-diagnostics' import { formatTerminalPasteExecutionError } from './terminal-paste-errors' import { @@ -181,12 +185,14 @@ describe('terminal paste coordinator', () => { expect(plan.mode).toBe('chunked') expect(plan.runtimeKey).toBe('ssh:prod') expect(pasteText).not.toHaveBeenCalled() - expect(writePty.mock.calls.map((call) => call[0]).join('')).toBe(text) + expect(writePty.mock.calls.map((call) => call[0]).join('')).toBe( + normalizeTerminalPasteLineEndings(text) + ) expect(writePty.mock.calls.length).toBeGreaterThan(1) expect(yieldToEventLoop).toHaveBeenCalledTimes(writePty.mock.calls.length) }) - it('bracket-wraps large terminal-mode paste once and preserves newlines', async () => { + it('bracket-wraps large terminal-mode paste once with xterm newline semantics', async () => { const text = 'alpha\r\nbeta\nbefore\x1b[201~after' const plan = planTerminalPaste({ text, @@ -200,7 +206,7 @@ describe('terminal paste coordinator', () => { expect(chunks[0]).toBe(BRACKETED_PASTE_START) expect(chunks.at(-1)).toBe(BRACKETED_PASTE_END) - expect(chunks.slice(1, -1).join('')).toBe('alpha\r\nbeta\nbefore␛[201~after') + expect(chunks.slice(1, -1).join('')).toBe('alpha\rbeta\rbefore␛[201~after') expect(chunks.slice(1, -1).join('')).not.toContain('\x1b[201~') }) @@ -264,7 +270,7 @@ describe('terminal paste coordinator', () => { expect(chunkTerminalPastePlan(plan)).toEqual([...iterateTerminalPastePlanChunks(plan)]) }) - it('preserves shared corpus payloads through non-bracketed chunk planning', () => { + it('applies terminal newline semantics to shared corpus payloads while chunking', () => { for (const { hasRichText = false, name, text } of PASTE_PAYLOAD_CORPUS) { const plan = planTerminalPaste({ hasRichText, @@ -278,7 +284,7 @@ describe('terminal paste coordinator', () => { expect(plan.mode, name).toBe('chunked') expect(plan.payload.hasRichText, name).toBe(hasRichText) - expect(chunks.join(''), name).toBe(text) + expect(chunks.join(''), name).toBe(normalizeTerminalPasteLineEndings(text)) expect(plan.redactedDiagnostic, name).toContain('content=redacted') expect(plan.redactedDiagnostic, name).toContain(`rich=${hasRichText}`) expect(plan.redactedDiagnostic, name).not.toContain(text) @@ -289,7 +295,7 @@ describe('terminal paste coordinator', () => { } }) - it('bracket-wraps shared non-control corpus payloads once without rewriting content', () => { + it('bracket-wraps shared non-control corpus payloads with terminal newline semantics', () => { for (const { expected, hasRichText = false, name, text } of PASTE_PAYLOAD_CORPUS) { if (expected.hasControlSequences) { continue @@ -309,7 +315,7 @@ describe('terminal paste coordinator', () => { expect(plan.bracketed, name).toBe(true) expect(chunks[0], name).toBe(BRACKETED_PASTE_START) expect(chunks.at(-1), name).toBe(BRACKETED_PASTE_END) - expect(chunks.slice(1, -1).join(''), name).toBe(text) + expect(chunks.slice(1, -1).join(''), name).toBe(normalizeTerminalPasteLineEndings(text)) expect( chunks.filter((chunk) => chunk === BRACKETED_PASTE_START), name @@ -322,7 +328,7 @@ describe('terminal paste coordinator', () => { } }) - it('preserves newline policy and literal text across terminal runtime identities', async () => { + it('uses xterm newline semantics across terminal runtime identities', async () => { const text = getPastePayloadCorpusText('mixed newline text') for (const { name, runtime } of RUNTIME_MATRIX) { @@ -344,10 +350,12 @@ describe('terminal paste coordinator', () => { }) expect(result.status, name).toBe('pasted') - expect(plan.newlinePolicy, name).toBe('preserve') + expect(plan.newlinePolicy, name).toBe('terminal-cr') expect(plan.runtimeKey, name).toBe(runtime.runtimeKey) expect(plan.redactedDiagnostic, name).toContain(`runtime=${runtime.runtimeKey}`) - expect(writePty.mock.calls.map((call) => call[0]).join(''), name).toBe(text) + expect(writePty.mock.calls.map((call) => call[0]).join(''), name).toBe( + normalizeTerminalPasteLineEndings(text) + ) } }) diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.ts b/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.ts index e04ba8ccbdf..ad4a77d5ecc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.ts @@ -173,7 +173,7 @@ function buildTerminalPastePlan({ target, payload, mode, - newlinePolicy: effectiveForceBracketedPaste ? 'terminal-cr' : 'preserve', + newlinePolicy: mode === 'chunked' || mode === 'bracketed-terminal' ? 'terminal-cr' : 'preserve', runtimeKey: target.runtime.runtimeKey, ...(shouldChunk ? { maxChunkBytes } : {}), bracketed: mode === 'bracketed-terminal' || (mode === 'chunked' && shouldBracketChunk), diff --git a/src/renderer/src/popout.tsx b/src/renderer/src/popout.tsx index 297f4ab5c11..fc07304e091 100644 --- a/src/renderer/src/popout.tsx +++ b/src/renderer/src/popout.tsx @@ -59,6 +59,9 @@ function PopoutSettingsSync(): null { useEffect(() => { let disposed = false + // Why: the preview terminal's copy/paste chords honor user keybinding + // overrides, which live in a separate file from settings. + void useAppStore.getState().fetchKeybindings() const setSettings = (next: GlobalSettings): void => { if (!disposed) { useAppStore.setState({ settings: next })