diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx index 4102e0fe59e..e1a26257374 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx @@ -12,6 +12,9 @@ const terminalHarness = vi.hoisted(() => ({ dispose: ReturnType resize: ReturnType reset: ReturnType + paste: ReturnType + selectionText: string + customKeyHandler: ((event: KeyboardEvent) => boolean) | null }[], userInputListener: null as (() => void) | null, userInputDispose: vi.fn() @@ -23,6 +26,8 @@ vi.mock('@xterm/xterm', () => ({ 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 +38,11 @@ vi.mock('@xterm/xterm', () => ({ dispose = vi.fn() resize = vi.fn() reset = vi.fn() + paste = vi.fn() + 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,9 +65,12 @@ 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('@/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' @@ -66,16 +79,21 @@ describe('AgentTerminalPreview', () => { 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 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 +105,14 @@ describe('AgentTerminalPreview', () => { emitData = listener return vi.fn() } + }, + ui: { + readClipboardText, + writeClipboardText, + onAppMenuPaste: (listener: () => void) => { + emitAppMenuPaste = listener + return vi.fn() + } } } }) @@ -120,6 +146,78 @@ describe('AgentTerminalPreview', () => { expect(ack).toHaveBeenCalledWith('pty-1', 4) }) + 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 handled = terminal.customKeyHandler!( + new KeyboardEvent('keydown', { key: 'C', code: 'KeyC', ctrlKey: true, shiftKey: true }) + ) + expect(handled).toBe(false) + expect(writeClipboardText).toHaveBeenCalledWith('selected text') + }) + + it('lets the copy chord fall through to xterm when nothing is selected', 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(true) + 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')) + }) + + 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 () => { + render() + await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) + const terminal = terminalHarness.instances[0]! + await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull()) + + const plain = terminal.customKeyHandler!( + new KeyboardEvent('keydown', { key: 'v', code: 'KeyV', ctrlKey: true }) + ) + expect(plain).toBe(true) + expect(readClipboardText).not.toHaveBeenCalled() + + const shifted = terminal.customKeyHandler!( + new KeyboardEvent('keydown', { key: 'V', code: 'KeyV', ctrlKey: true, shiftKey: true }) + ) + expect(shifted).toBe(false) + await waitFor(() => expect(terminal.paste).toHaveBeenCalledWith('clip-text')) + expect(readClipboardText).toHaveBeenCalledTimes(1) + }) + 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..89efae286f9 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx @@ -2,11 +2,14 @@ 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 { TERMINAL_PASTE_MAX_BYTES } from '@/components/terminal-pane/terminal-paste-limits' 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' @@ -115,6 +118,56 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El }) } + const pasteClipboardText = async (): Promise => { + let text: string + try { + text = await window.api.ui.readClipboardText({ maxBytes: TERMINAL_PASTE_MAX_BYTES }) + } catch { + return + } + if (!disposed && terminal && text) { + // Why: paste() flows through onData as user input, so the existing PTY routing applies. + terminal.paste(text) + } + } + + const installClipboardShortcuts = (): void => { + if (!terminal) { + return + } + const platform = getShortcutPlatform() + terminal.attachCustomKeyEventHandler((event) => { + if (event.type !== 'keydown') { + return true + } + const keybindings = useAppStore.getState().keybindings + if (keybindingMatchesAction('terminal.copySelection', event, platform, keybindings)) { + const selection = terminal?.getSelection() + if (!selection) { + return true + } + void window.api.ui.writeClipboardText(selection).catch(() => undefined) + return false + } + // 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) + ) { + void pasteClipboardText() + return false + } + return true + }) + } + const installInputRouting = (): void => { if (!terminal) { return @@ -158,6 +211,7 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El return } installInputRouting() + installClipboardShortcuts() } else if (replaceExisting) { // Why: keep the old frame visible during capture, then atomically replace it once the authoritative snapshot arrives. terminal.resize( @@ -235,6 +289,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() + } + }) + offData = window.api.terminalPreview.onData((payload) => { if (payload.ptyId !== ptyId) { return @@ -253,6 +317,7 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El if (retryTimer) { clearTimeout(retryTimer) } + offAppMenuPaste() offData?.() userInputDisposable?.dispose() void window.api.terminalPreview.unsubscribe(ptyId) 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 })