From 7aae88cd216affd72df82f78d6ecc8ed0bed432f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:52:13 -0700 Subject: [PATCH] Fix terminal select-all and copy in Kitty-enabled TUIs (#13388) * fix(terminal): handle select-all in Kitty TUIs * fix(terminal): preserve popout native selection --- src/main/ipc/register-core-handlers.test.ts | 10 +- src/main/ipc/register-core-handlers.ts | 3 +- src/main/ipc/ui.test.ts | 44 +++++++++ src/main/ipc/ui.ts | 21 ++++- src/main/menu/app-menu-selection-item.ts | 37 ++++++++ src/main/menu/register-app-menu.test.ts | 73 ++++++++++++++- src/main/menu/register-app-menu.ts | 13 ++- src/preload/api-types.ts | 2 + src/preload/index.ts | 9 ++ src/renderer/src/App.tsx | 2 + .../AgentTerminalPreview.test.tsx | 91 ++++++++++++++++++- .../dashboard-popout/AgentTerminalPreview.tsx | 15 ++- .../preview-terminal-app-menu-clipboard.ts | 43 +++++++++ .../preview-terminal-key-handler.ts | 18 +++- .../TerminalContextMenu.test.tsx | 3 + .../terminal-pane/TerminalContextMenu.tsx | 9 ++ .../components/terminal-pane/TerminalPane.tsx | 43 +++++++++ .../terminal-pane/keyboard-handlers.ts | 17 +++- ...epro-8299-shift-space-input-source.test.ts | 9 ++ .../terminal-native-only-shortcut.ts | 5 +- .../terminal-pane/terminal-shortcut-policy.ts | 7 ++ .../terminal-shortcut-select-all.test.ts | 36 ++++++++ .../use-terminal-pane-context-menu.ts | 10 ++ .../terminal-pane/xterm-bypass-policy.test.ts | 4 +- .../hooks/useAppMenuSelectionActions.test.tsx | 56 ++++++++++++ .../src/hooks/useAppMenuSelectionActions.ts | 15 +++ src/renderer/src/i18n/locales/en.json | 5 +- src/renderer/src/i18n/locales/es.json | 5 +- src/renderer/src/i18n/locales/ja.json | 5 +- src/renderer/src/i18n/locales/ko.json | 5 +- src/renderer/src/i18n/locales/zh.json | 5 +- .../lib/app-menu-selection-actions.test.ts | 22 +++++ .../src/lib/app-menu-selection-actions.ts | 15 +++ src/renderer/src/web/web-preload-api.ts | 4 + src/shared/keybindings.test.ts | 53 ++++++++++- src/shared/keybindings.ts | 19 +++- 36 files changed, 702 insertions(+), 31 deletions(-) create mode 100644 src/main/menu/app-menu-selection-item.ts create mode 100644 src/renderer/src/components/dashboard-popout/preview-terminal-app-menu-clipboard.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-shortcut-select-all.test.ts create mode 100644 src/renderer/src/hooks/useAppMenuSelectionActions.test.tsx create mode 100644 src/renderer/src/hooks/useAppMenuSelectionActions.ts create mode 100644 src/renderer/src/lib/app-menu-selection-actions.test.ts create mode 100644 src/renderer/src/lib/app-menu-selection-actions.ts diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 707c6fd7c69..55043e2b526 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -56,6 +56,7 @@ const { registerCodexConfigSyncHandlersMock, registerOnboardingHandlersMock, registerDashboardPopoutHandlersMock, + isDashboardPopoutRendererMock, registerTerminalPreviewHandlersMock, registerSpeechHandlersMock, registerSkillsHandlersMock, @@ -121,6 +122,7 @@ const { registerCodexConfigSyncHandlersMock: vi.fn(), registerOnboardingHandlersMock: vi.fn(), registerDashboardPopoutHandlersMock: vi.fn(), + isDashboardPopoutRendererMock: vi.fn(), registerTerminalPreviewHandlersMock: vi.fn(), registerSpeechHandlersMock: vi.fn(), registerSkillsHandlersMock: vi.fn(), @@ -158,6 +160,10 @@ vi.mock('./dashboard-popout', () => ({ registerDashboardPopoutHandlers: registerDashboardPopoutHandlersMock })) +vi.mock('../window/dashboard-popout-window', () => ({ + isDashboardPopoutRenderer: isDashboardPopoutRendererMock +})) + vi.mock('./terminal-preview', () => ({ registerTerminalPreviewHandlers: registerTerminalPreviewHandlersMock })) @@ -529,7 +535,9 @@ describe('registerCoreHandlers', () => { expect(registerTelemetryHandlersMock).toHaveBeenCalledWith(store) expect(registerOrcaProfileHandlersMock).toHaveBeenCalledWith(store, { onBeforeRelaunch }) expect(registerSessionHandlersMock).toHaveBeenCalledWith(store) - expect(registerUIHandlersMock).toHaveBeenCalledWith(store) + expect(registerUIHandlersMock).toHaveBeenCalledWith(store, { + isDashboardPopoutRenderer: isDashboardPopoutRendererMock + }) expect(registerEmulatorFrameStreamHandlersMock).toHaveBeenCalled() expect(registerEmulatorVideoStreamHandlersMock).toHaveBeenCalled() expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index c461900f9fa..b685f470eb0 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -71,6 +71,7 @@ import { registerClipboardHandlers, setTrustedClipboardRendererWebContentsId } from '../window/clipboard-ipc-handlers' +import { isDashboardPopoutRenderer } from '../window/dashboard-popout-window' import type { ClaudeUsageStore } from '../claude-usage/store' import type { CodexUsageStore } from '../codex-usage/store' import type { OpenCodeUsageStore } from '../opencode-usage/store' @@ -201,7 +202,7 @@ export function registerCoreHandlers( registerShellHandlers(store) registerPetHandlers() registerSessionHandlers(store) - registerUIHandlers(store) + registerUIHandlers(store, { isDashboardPopoutRenderer }) registerEmulatorFrameStreamHandlers() registerEmulatorVideoStreamHandlers() registerWorkspaceSpaceHandlers(store) diff --git a/src/main/ipc/ui.test.ts b/src/main/ipc/ui.test.ts index 398efa8065a..9fce31d313f 100644 --- a/src/main/ipc/ui.test.ts +++ b/src/main/ipc/ui.test.ts @@ -71,6 +71,12 @@ function getNativePasteHandler(): return onMock.mock.calls.find(([channel]) => channel === 'ui:performNativePaste')?.[1] } +function getNativeSelectionActionHandler(): + | ((event: ReturnType, action: unknown) => void) + | undefined { + return onMock.mock.calls.find(([channel]) => channel === 'ui:performNativeSelectionAction')?.[1] +} + describe('UI IPC', () => { beforeEach(() => { vi.stubEnv('ELECTRON_RENDERER_URL', '') @@ -187,6 +193,44 @@ describe('UI IPC', () => { expect(pasteAndMatchStyle).toHaveBeenCalledTimes(1) }) + it('routes native selection fallbacks to the requesting webContents', () => { + const copy = vi.fn() + const selectAll = vi.fn() + const event = makeUIEvent() + setTrustedUIRendererWebContentsId(17) + fromWebContentsMock.mockReturnValue({ webContents: { copy, selectAll } }) + + registerUIHandlers(makeStore() as never) + + expect(removeAllListenersMock).toHaveBeenCalledWith('ui:performNativeSelectionAction') + const handler = getNativeSelectionActionHandler() + handler?.(event, 'copy') + handler?.(event, 'select-all') + handler?.(event, 'invalid') + + expect(copy).toHaveBeenCalledOnce() + expect(selectAll).toHaveBeenCalledOnce() + }) + + it('allows native selection fallback from the exact dashboard popout renderer', () => { + const copy = vi.fn() + const selectAll = vi.fn() + const event = makeUIEvent({ id: 42 }) + const isDashboardPopoutRenderer = vi.fn((sender: unknown) => sender === event.sender) + fromWebContentsMock.mockReturnValue({ webContents: { copy, selectAll } }) + + registerUIHandlers(makeStore() as never, { isDashboardPopoutRenderer }) + + const handler = getNativeSelectionActionHandler() + handler?.(event, 'copy') + handler?.(event, 'select-all') + handler?.(makeUIEvent({ id: 43 }), 'copy') + + expect(isDashboardPopoutRenderer).toHaveBeenCalledWith(event.sender) + expect(copy).toHaveBeenCalledOnce() + expect(selectAll).toHaveBeenCalledOnce() + }) + it('ignores native paste fallback from stale or browser senders', () => { const paste = vi.fn() const pasteAndMatchStyle = vi.fn() diff --git a/src/main/ipc/ui.ts b/src/main/ipc/ui.ts index 83744b0404b..8d2abe92d84 100644 --- a/src/main/ipc/ui.ts +++ b/src/main/ipc/ui.ts @@ -44,7 +44,10 @@ export function getTrustedUIRendererWindow(): BrowserWindow | null { return renderer ? BrowserWindow.fromWebContents(renderer) : null } -export function registerUIHandlers(store: Store): void { +export function registerUIHandlers( + store: Store, + options: { isDashboardPopoutRenderer?: (sender: WebContents) => boolean } = {} +): void { // Why: UI view-state is shared between the desktop renderer and mobile (ui.set // RPC). Broadcast every change so the desktop re-hydrates when mobile (or // another window) updates it — bi-directional sync, mirroring settings:changed. @@ -85,6 +88,22 @@ export function registerUIHandlers(store: Store): void { } webContents?.paste() }) + + ipcMain.removeAllListeners('ui:performNativeSelectionAction') + ipcMain.on('ui:performNativeSelectionAction', (event, action: unknown) => { + if ( + !isTrustedUIRenderer(event.sender) && + options.isDashboardPopoutRenderer?.(event.sender) !== true + ) { + return + } + const target = BrowserWindow.fromWebContents(event.sender)?.webContents + if (action === 'copy') { + target?.copy() + } else if (action === 'select-all') { + target?.selectAll() + } + }) } export function isTrustedUIRenderer(sender: WebContents): boolean { diff --git a/src/main/menu/app-menu-selection-item.ts b/src/main/menu/app-menu-selection-item.ts new file mode 100644 index 00000000000..e7c5d64a07b --- /dev/null +++ b/src/main/menu/app-menu-selection-item.ts @@ -0,0 +1,37 @@ +import { BrowserWindow, Menu, webContents } from 'electron' + +export type AppMenuSelectionAction = 'copy' | 'select-all' + +export function createAppMenuSelectionItem({ + action, + label, + isMac +}: { + action: AppMenuSelectionAction + label: string + isMac: boolean +}): Electron.MenuItemConstructorOptions { + return { + label, + ...(isMac ? { accelerator: action === 'copy' ? 'Command+C' : 'Command+A' } : {}), + click: () => { + const focusedWindow = BrowserWindow.getFocusedWindow() + if (focusedWindow) { + const focusedContents = webContents.getFocusedWebContents() + if (focusedContents && focusedContents !== focusedWindow.webContents) { + if (action === 'copy') { + focusedContents.copy() + } else { + focusedContents.selectAll() + } + return + } + focusedWindow.webContents.send('ui:appMenuSelectionAction', action) + return + } + if (isMac) { + Menu.sendActionToFirstResponder(action === 'copy' ? 'copy:' : 'selectAll:') + } + } + } +} diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index e771cae9090..82d1bc184de 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -4,11 +4,13 @@ const { buildFromTemplateMock, setApplicationMenuMock, getFocusedWindowMock, + getFocusedWebContentsMock, sendActionToFirstResponderMock } = vi.hoisted(() => ({ buildFromTemplateMock: vi.fn(), setApplicationMenuMock: vi.fn(), getFocusedWindowMock: vi.fn(), + getFocusedWebContentsMock: vi.fn(), sendActionToFirstResponderMock: vi.fn() })) @@ -23,6 +25,9 @@ vi.mock('electron', () => ({ }, app: { name: 'Orca' + }, + webContents: { + getFocusedWebContents: getFocusedWebContentsMock } })) @@ -77,6 +82,7 @@ describe('registerAppMenu', () => { buildFromTemplateMock.mockReset() setApplicationMenuMock.mockReset() getFocusedWindowMock.mockReset() + getFocusedWebContentsMock.mockReset() sendActionToFirstResponderMock.mockReset() buildFromTemplateMock.mockImplementation((template) => ({ template })) }) @@ -233,7 +239,9 @@ describe('registerAppMenu', () => { (platform) => { vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) const send = vi.fn() - getFocusedWindowMock.mockReturnValue({ webContents: { send } }) + const hostContents = { send } + getFocusedWindowMock.mockReturnValue({ webContents: hostContents }) + getFocusedWebContentsMock.mockReturnValue(hostContents) registerAppMenu(buildMenuOptions()) const editSubmenu = getSubmenu(getTemplate(), 'Edit') @@ -251,6 +259,69 @@ describe('registerAppMenu', () => { } ) + it('keeps selection actions native in a focused guest webview', () => { + const send = vi.fn() + const guestContents = { copy: vi.fn(), selectAll: vi.fn() } + getFocusedWindowMock.mockReturnValue({ webContents: { send } }) + getFocusedWebContentsMock.mockReturnValue(guestContents) + registerAppMenu(buildMenuOptions()) + + const editSubmenu = getSubmenu(getTemplate(), 'Edit') + editSubmenu + .find((item) => item.label === 'Copy') + ?.click?.({} as never, {} as never, {} as never) + editSubmenu + .find((item) => item.label === 'Select All') + ?.click?.({} as never, {} as never, {} as never) + + expect(guestContents.copy).toHaveBeenCalledOnce() + expect(guestContents.selectAll).toHaveBeenCalledOnce() + expect(send).not.toHaveBeenCalled() + }) + + it.each(['darwin', 'linux', 'win32'] as const)( + 'routes Edit selection actions through the focused Orca window on %s', + (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + const send = vi.fn() + getFocusedWindowMock.mockReturnValue({ webContents: { send } }) + registerAppMenu(buildMenuOptions()) + + const editSubmenu = getSubmenu(getTemplate(), 'Edit') + const copyItem = editSubmenu.find((item) => item.label === 'Copy') + const selectAllItem = editSubmenu.find((item) => item.label === 'Select All') + + expect(copyItem?.role).toBeUndefined() + expect(selectAllItem?.role).toBeUndefined() + expect(copyItem?.accelerator).toBe(platform === 'darwin' ? 'Command+C' : undefined) + expect(selectAllItem?.accelerator).toBe(platform === 'darwin' ? 'Command+A' : undefined) + + copyItem?.click?.({} as never, {} as never, {} as never) + selectAllItem?.click?.({} as never, {} as never, {} as never) + + expect(send.mock.calls).toEqual([ + ['ui:appMenuSelectionAction', 'copy'], + ['ui:appMenuSelectionAction', 'select-all'] + ]) + } + ) + + it('routes macOS selection actions to the native responder without a focused window', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + getFocusedWindowMock.mockReturnValue(null) + registerAppMenu(buildMenuOptions()) + + const editSubmenu = getSubmenu(getTemplate(), 'Edit') + editSubmenu + .find((item) => item.label === 'Copy') + ?.click?.({} as never, {} as never, {} as never) + editSubmenu + .find((item) => item.label === 'Select All') + ?.click?.({} as never, {} as never, {} as never) + + expect(sendActionToFirstResponderMock.mock.calls).toEqual([['copy:'], ['selectAll:']]) + }) + it('routes Edit > Paste to the native first responder once on macOS without a focused window', () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') getFocusedWindowMock.mockReturnValue(null) diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index 3bca33268f9..2feeef9fe47 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -7,6 +7,7 @@ import { } from '../../shared/keybindings' import type { UpdateCheckOptions } from '../../shared/types' import { translateMain } from '../i18n/main-i18n' +import { createAppMenuSelectionItem } from './app-menu-selection-item' export type AppearanceMenuState = { showTasksButton: boolean @@ -173,7 +174,11 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { { role: 'redo' }, { type: 'separator' }, { role: 'cut' }, - { role: 'copy' }, + createAppMenuSelectionItem({ + action: 'copy', + label: translateMain('menu.copy', 'Copy'), + isMac + }), { label: translateMain('menu.paste', 'Paste'), accelerator: 'CmdOrCtrl+V', @@ -193,7 +198,11 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { } } }, - { role: 'selectAll' } + createAppMenuSelectionItem({ + action: 'select-all', + label: translateMain('menu.selectAll', 'Select All'), + isMac + }) ] } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 001b4239224..bc6abd8cc03 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -3229,6 +3229,7 @@ export type PreloadApi = { onDictationKeyDown: (callback: () => void) => () => void onExportPdfRequested: (callback: () => void) => () => void onAppMenuPaste: (callback: () => void) => () => void + onAppMenuSelectionAction: (callback: (action: 'copy' | 'select-all') => void) => () => void onEditableContextPaste: (callback: (data: { plainTextOnly: boolean }) => void) => () => void onActivateWorktree: ( callback: (data: { @@ -3343,6 +3344,7 @@ export type PreloadApi = { writeSelectionClipboardText: (text: string) => Promise writeClipboardImage: (dataUrl: string) => Promise performNativePaste: (options?: { mode?: 'paste' | 'paste-and-match-style' }) => void + performNativeSelectionAction: (action: 'copy' | 'select-all') => void writeClipboardFile: ( args: | { diff --git a/src/preload/index.ts b/src/preload/index.ts index fc9dbeebf37..8b192819496 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3753,6 +3753,12 @@ const api = { ipcRenderer.on('ui:appMenuPaste', listener) return () => ipcRenderer.removeListener('ui:appMenuPaste', listener) }, + onAppMenuSelectionAction: (callback: (action: 'copy' | 'select-all') => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, action: 'copy' | 'select-all'): void => + callback(action) + ipcRenderer.on('ui:appMenuSelectionAction', listener) + return () => ipcRenderer.removeListener('ui:appMenuSelectionAction', listener) + }, onEditableContextPaste: ( callback: (data: { plainTextOnly: boolean }) => void ): (() => void) => { @@ -4066,6 +4072,9 @@ const api = { mode: options?.mode === 'paste-and-match-style' ? 'paste-and-match-style' : 'paste' }) }, + performNativeSelectionAction: (action: 'copy' | 'select-all'): void => { + ipcRenderer.send('ui:performNativeSelectionAction', action) + }, writeClipboardFile: ( args: | { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 2c253256301..7529436ca93 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -102,6 +102,7 @@ import { usePrimarySelectionPaste } from './hooks/usePrimarySelectionPaste' import { useAppMenuPaste } from './hooks/useAppMenuPaste' +import { useAppMenuSelectionActions } from './hooks/useAppMenuSelectionActions' import { useLargeTextControlPaste } from './hooks/useLargeTextControlPaste' import { canSkipRuntimeMobileSessionSyncKeyBuild, @@ -720,6 +721,7 @@ function App(): React.JSX.Element { usePrimarySelectionPaste(primarySelectionMiddleClickPaste) useAppMenuPaste() + useAppMenuSelectionActions() useLargeTextControlPaste() const petEnabled = useAppStore((s) => s.settings?.experimentalPet === true) const petVisible = useAppStore((s) => s.petVisible) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx index 74183ea1b84..2d72b7304f9 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx @@ -24,6 +24,7 @@ const terminalHarness = vi.hoisted(() => ({ input: ReturnType scrollToTop: ReturnType scrollToBottom: ReturnType + selectAll: ReturnType modes: { bracketedPasteMode: boolean } selectionText: string customKeyHandler: ((event: KeyboardEvent) => boolean) | null @@ -82,6 +83,7 @@ vi.mock('@xterm/xterm', () => ({ attachCustomWheelEventHandler = vi.fn() scrollToTop = vi.fn() scrollToBottom = vi.fn() + selectAll = vi.fn() getSelection = vi.fn(() => this.selectionText) attachCustomKeyEventHandler = vi.fn((handler: (event: KeyboardEvent) => boolean) => { this.customKeyHandler = handler @@ -149,6 +151,7 @@ describe('AgentTerminalPreview', () => { const writeTerminalClipboardText = vi.fn(async () => {}) let emitData: ((payload: unknown) => void) | null let emitAppMenuPaste: (() => void) | null + let emitAppMenuSelectionAction: ((action: 'copy' | 'select-all') => void) | null beforeEach(() => { terminalHarness.instances.length = 0 @@ -160,6 +163,7 @@ describe('AgentTerminalPreview', () => { imeHarness.claimResult = false emitData = null emitAppMenuPaste = null + emitAppMenuSelectionAction = null connect.mockResolvedValue({ snapshot: { data: '', cols: 80, rows: 24, seq: 1 }, replay: [] @@ -185,7 +189,12 @@ describe('AgentTerminalPreview', () => { onAppMenuPaste: (listener: () => void) => { emitAppMenuPaste = listener return vi.fn() - } + }, + onAppMenuSelectionAction: (listener: (action: 'copy' | 'select-all') => void) => { + emitAppMenuSelectionAction = listener + return vi.fn() + }, + performNativeSelectionAction: vi.fn() } } }) @@ -237,7 +246,7 @@ describe('AgentTerminalPreview', () => { imeHarness.claimResult = true terminal.selectionText = 'selected text' const handled = terminal.customKeyHandler!( - new KeyboardEvent('keydown', { key: 'C', code: 'KeyC', metaKey: true, shiftKey: true }) + new KeyboardEvent('keydown', { key: 'C', code: 'KeyC', ctrlKey: true, shiftKey: true }) ) expect(handled).toBe(false) expect(writeClipboardText).not.toHaveBeenCalled() @@ -246,7 +255,7 @@ describe('AgentTerminalPreview', () => { // 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 }) + new KeyboardEvent('keydown', { key: 'c', code: 'KeyC', metaKey: true }) ) expect(copied).toBe(false) expect(writeTerminalClipboardText).toHaveBeenCalledWith('selected text') @@ -327,6 +336,47 @@ describe('AgentTerminalPreview', () => { expect(writeTerminalClipboardText).not.toHaveBeenCalled() }) + it('leaves bare Ctrl+C available to the terminal without a selection', 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 }) + ) + expect(handled).toBe(true) + expect(writeTerminalClipboardText).not.toHaveBeenCalled() + }) + + it('selects all terminal text on Cmd+A and blocks xterm 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()) + + const keydown = new KeyboardEvent('keydown', { + key: 'a', + code: 'KeyA', + metaKey: true, + cancelable: true + }) + expect(terminal.customKeyHandler!(keydown)).toBe(false) + expect(keydown.defaultPrevented).toBe(true) + + const repeat = new KeyboardEvent('keydown', { + key: 'a', + code: 'KeyA', + metaKey: true, + repeat: true, + cancelable: true + }) + expect(terminal.customKeyHandler!(repeat)).toBe(false) + expect(repeat.defaultPrevented).toBe(true) + expect(terminal.selectAll).toHaveBeenCalledOnce() + }) + 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)) @@ -343,6 +393,41 @@ describe('AgentTerminalPreview', () => { expect(input).toHaveBeenCalledWith('pty-1', 'clip-text') }) + it('handles app-menu selection actions while the preview owns focus', async () => { + const view = render() + await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) + const terminal = terminalHarness.instances[0]! + terminal.selectionText = 'selected text' + + const host = view.container.querySelector('.origin-bottom-left')! + const focusTarget = document.createElement('textarea') + focusTarget.className = 'xterm-helper-textarea' + host.appendChild(focusTarget) + focusTarget.focus() + + act(() => emitAppMenuSelectionAction?.('select-all')) + act(() => emitAppMenuSelectionAction?.('copy')) + + expect(terminal.selectAll).toHaveBeenCalledOnce() + await waitFor(() => expect(writeTerminalClipboardText).toHaveBeenCalledWith('selected text')) + }) + + it('keeps app-menu selection actions native for text controls inside the preview', async () => { + const view = render() + await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) + const terminal = terminalHarness.instances[0]! + + const host = view.container.querySelector('.origin-bottom-left')! + const focusTarget = document.createElement('input') + host.appendChild(focusTarget) + focusTarget.focus() + + act(() => emitAppMenuSelectionAction?.('select-all')) + + expect(terminal.selectAll).not.toHaveBeenCalled() + expect(window.api.ui.performNativeSelectionAction).toHaveBeenCalledWith('select-all') + }) + it('ignores the app-menu paste signal when focus is outside the preview', 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 50d65df9193..d187694b841 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx @@ -22,6 +22,7 @@ import { cn } from '@/lib/utils' import { useAppStore } from '@/store' import { installPreviewTerminalKeyHandler } from './preview-terminal-key-handler' import { createPreviewGridClaim } from './preview-grid-claim' +import { installPreviewTerminalAppMenuClipboard } from './preview-terminal-app-menu-clipboard' import type { TerminalPreviewDataPayload } from '../../../../shared/terminal-preview' const PREVIEW_SCROLLBACK_ROWS = 24 @@ -383,14 +384,10 @@ export function AgentTerminalPreview({ 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') - } + const disposeAppMenuClipboard = installPreviewTerminalAppMenuClipboard({ + container, + getTerminal: () => terminal, + pasteClipboardText }) offData = window.api.terminalPreview.onData((payload) => { @@ -413,7 +410,7 @@ export function AgentTerminalPreview({ } gridClaim.dispose() boxResizeObserver?.disconnect() - offAppMenuPaste() + disposeAppMenuClipboard() offData?.() userInputDisposable?.dispose() disposeImeNativeTextBridge() diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-app-menu-clipboard.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-app-menu-clipboard.ts new file mode 100644 index 00000000000..5f0fecf7164 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-app-menu-clipboard.ts @@ -0,0 +1,43 @@ +import type { Terminal } from '@xterm/xterm' +import { isEditableTarget } from '@/lib/editable-target' + +type PreviewTerminalSelection = Pick + +export function installPreviewTerminalAppMenuClipboard({ + container, + getTerminal, + pasteClipboardText +}: { + container: HTMLElement + getTerminal: () => PreviewTerminalSelection | null + pasteClipboardText: (activeElementAtDispatch: Element | null, source: 'app-menu') => Promise +}): () => void { + const offPaste = window.api.ui.onAppMenuPaste(() => { + const active = document.activeElement + if (active && container.contains(active)) { + void pasteClipboardText(active, 'app-menu') + } + }) + const offSelection = window.api.ui.onAppMenuSelectionAction((action) => { + const active = document.activeElement + const terminal = getTerminal() + if (!active || !container.contains(active) || isEditableTarget(active) || !terminal) { + window.api.ui.performNativeSelectionAction(action) + return + } + if (action === 'select-all') { + terminal.selectAll() + return + } + const selection = terminal.getSelection() + if (selection) { + void window.api.ui.writeTerminalClipboardText(selection).catch(() => undefined) + } else { + window.api.ui.performNativeSelectionAction(action) + } + }) + return () => { + offPaste() + offSelection() + } +} diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts index c39cf94fc3d..0375f5170e3 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts @@ -103,10 +103,20 @@ export function installPreviewTerminalKeyHandler(args: { nativeOnlyShortcutTracker.prepareKeyDown(event) const keybindings = useAppStore.getState().keybindings if (keybindingMatchesAction('terminal.copySelection', event, platform, keybindings)) { + const selection = terminal.getSelection() + if ( + !selection && + platform !== 'darwin' && + event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey + ) { + return true + } 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.writeTerminalClipboardText(selection).catch(() => undefined) } @@ -147,6 +157,12 @@ export function installPreviewTerminalKeyHandler(args: { terminal.scrollToBottom() } return consumeEvent(event) + case 'selectAll': + if (!event.repeat) { + nativeOnlyShortcutTracker.armKeyDown(event) + terminal.selectAll() + } + return consumeEvent(event) case 'switchInputSource': // Why: the OS owns this chord — block xterm without preventing the default. nativeOnlyShortcutTracker.armKeyDown(event) diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx index 230c2d7d8e9..6deef0b5864 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx @@ -65,6 +65,7 @@ function renderMenu(overrides: Record = {}): string { canExpandPane: true, menuPaneIsExpanded: false, onCopy: vi.fn(), + onSelectAll: vi.fn(), onPaste: vi.fn(), onSplitRight: vi.fn(), onSplitDown: vi.fn(), @@ -148,6 +149,7 @@ describe('TerminalContextMenu', () => { }) const keybindings = { 'terminal.copySelection': ['Ctrl+Shift+C', 'Ctrl+Insert', 'Ctrl+C'], + 'terminal.selectAll': ['Ctrl+Shift+A'], 'terminal.splitRight': ['Mod+Shift+D', 'Alt+Shift+Right'], 'terminal.splitDown': ['Alt+Shift+D', 'Mod+Shift+Minus'] } satisfies KeybindingOverrides @@ -155,6 +157,7 @@ describe('TerminalContextMenu', () => { renderMenu({ keybindings }) expect(shortcuts.list).toContain('Ctrl+Shift+C') + expect(shortcuts.list).toContain('Ctrl+Shift+A') expect(shortcuts.list).toContain('Ctrl+V') expect(shortcuts.list).toContain('Ctrl+Shift+D') expect(shortcuts.list).toContain('Alt+Shift+D') diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index 01740d33854..c983b389760 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -13,6 +13,7 @@ import { PanelRightClose, Pencil, SquareTerminal, + TextSelect, X } from 'lucide-react' import { @@ -43,6 +44,7 @@ type TerminalContextMenuProps = { canExpandPane: boolean menuPaneIsExpanded: boolean onCopy: () => void + onSelectAll: () => void onPaste: () => void onSplitRight: () => void onSplitDown: () => void @@ -81,6 +83,7 @@ export default function TerminalContextMenu({ canExpandPane, menuPaneIsExpanded, onCopy, + onSelectAll, onPaste, onSplitRight, onSplitDown, @@ -113,6 +116,7 @@ export default function TerminalContextMenu({ const shortcuts = useMemo( () => ({ copy: formatPrimaryShortcutLabel('terminal.copySelection', keybindings), + selectAll: formatPrimaryShortcutLabel('terminal.selectAll', keybindings), paste: formatPrimaryShortcutLabel('terminal.paste', keybindings), splitRight: formatPrimaryShortcutLabel('terminal.splitRight', keybindings), splitDown: formatPrimaryShortcutLabel('terminal.splitDown', keybindings), @@ -175,6 +179,11 @@ export default function TerminalContextMenu({ {translate('auto.components.terminal.pane.TerminalContextMenu.f3eeb1de13', 'Copy')} {shortcuts.copy} + + + {translate('auto.components.terminal.pane.TerminalContextMenu.selectAll', 'Select All')} + {shortcuts.selectAll} + {translate('auto.components.terminal.pane.TerminalContextMenu.0a917b591a', 'Paste')} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 36eadb69683..e4862c5f085 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -138,6 +138,12 @@ import { readPrimarySelectionText } from '@/lib/primary-selection' import { APP_MENU_PASTE_EVENT } from '@/lib/app-menu-paste' +import { + APP_MENU_SELECTION_ACTION_EVENT, + type AppMenuSelectionAction +} from '@/lib/app-menu-selection-actions' +import { isEditableTarget } from '@/lib/editable-target' +import { copyTerminalSelection } from './terminal-selection-copy' import { CODEX_ACCOUNT_RESTART_STARTUP } from '@/lib/codex-session-restart' import { WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag' import { isTerminalSessionStateSaveFailure } from '../../../../shared/terminal-session-state-save-failure' @@ -2159,9 +2165,44 @@ function TerminalPane( }) } + const onAppMenuSelectionAction = (event: Event): void => { + const activeElement = document.activeElement + if ( + !(activeElement instanceof Element) || + !container.contains(activeElement) || + isEditableTarget(activeElement) || + activeElement.closest('[data-terminal-search-root]') || + isInsideNativeChatRoot(activeElement) + ) { + return + } + const manager = managerRef.current + const pane = manager?.getActivePane() ?? manager?.getPanes()[0] + if (!pane) { + return + } + const action = (event as CustomEvent).detail + if (action === 'copy') { + if (!pane.terminal.getSelection()) { + return + } + event.preventDefault() + void copyTerminalSelection({ + terminal: pane.terminal, + writeClipboardText: window.api.ui.writeTerminalClipboardText + }).catch(() => undefined) + return + } + if (action === 'select-all') { + event.preventDefault() + pane.terminal.selectAll() + } + } + container.addEventListener('keydown', onKeyPaste, { capture: true }) container.addEventListener('paste', onPaste, { capture: true }) window.addEventListener(APP_MENU_PASTE_EVENT, onAppMenuPaste) + window.addEventListener(APP_MENU_SELECTION_ACTION_EVENT, onAppMenuSelectionAction) return () => { if (pasteSuppressionTimerId !== null) { window.clearTimeout(pasteSuppressionTimerId) @@ -2169,6 +2210,7 @@ function TerminalPane( container.removeEventListener('keydown', onKeyPaste, { capture: true }) container.removeEventListener('paste', onPaste, { capture: true }) window.removeEventListener(APP_MENU_PASTE_EVENT, onAppMenuPaste) + window.removeEventListener(APP_MENU_SELECTION_ACTION_EVENT, onAppMenuSelectionAction) } }, [isActive, worktreeId, keybindings, forceBracketedMultilineTextPaste, tabId]) @@ -3038,6 +3080,7 @@ function TerminalPane( contextMenu.menuPaneId !== null && contextMenu.menuPaneId === expandedPaneId } onCopy={() => void contextMenu.onCopy()} + onSelectAll={contextMenu.onSelectAll} onPaste={() => void contextMenu.onPaste()} onSplitRight={contextMenu.onSplitRight} onSplitDown={contextMenu.onSplitDown} diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index 6580bbb81b8..8353f4201bf 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -633,12 +633,25 @@ export function useTerminalKeyboardShortcuts({ return } + if (action.type === 'selectAll') { + const pane = manager.getActivePane() ?? manager.getPanes()[0] + if (!pane) { + return + } + if (!e.repeat) { + nativeOnlyShortcutTracker.armKeyDown(e) + pane.terminal.selectAll() + } + e.preventDefault() + e.stopImmediatePropagation() + return + } + if (e.repeat) { return } - // Cmd/Ctrl+Shift+C copies terminal selection via Electron clipboard. - // This ensures Linux terminal copy works consistently. + // Why: bypass xterm's hidden textarea and Kitty encoder for terminal copy bindings. if (action.type === 'copySelection') { const pane = manager.getActivePane() ?? manager.getPanes()[0] if (!pane) { diff --git a/src/renderer/src/components/terminal-pane/repro-8299-shift-space-input-source.test.ts b/src/renderer/src/components/terminal-pane/repro-8299-shift-space-input-source.test.ts index 3bdfb801ae7..e5b7f7de338 100644 --- a/src/renderer/src/components/terminal-pane/repro-8299-shift-space-input-source.test.ts +++ b/src/renderer/src/components/terminal-pane/repro-8299-shift-space-input-source.test.ts @@ -161,6 +161,15 @@ describe('issue #8299 Shift+Space input-source switch regression', () => { expect(tracker.consumeCompanion({ type: 'keyup', key: ' ', code: 'Space' })).toBe(false) }) + it('keeps a native-only key armed through held-key repeats', () => { + const tracker = createTerminalNativeOnlyShortcutTracker() + tracker.armKeyDown({ key: 'a', code: 'KeyA' }) + tracker.prepareKeyDown({ key: 'a', code: 'KeyA', repeat: true }) + + expect(tracker.consumeCompanion({ type: 'keypress', key: 'a', code: 'KeyA' })).toBe(true) + expect(tracker.consumeCompanion({ type: 'keyup', key: 'a', code: 'KeyA' })).toBe(true) + }) + it('suppresses only the shortcut text on the beforeinput fallback', () => { const tracker = createTerminalNativeOnlyShortcutTracker() tracker.armKeyDown({ key: ' ', code: 'Space' }) diff --git a/src/renderer/src/components/terminal-pane/terminal-native-only-shortcut.ts b/src/renderer/src/components/terminal-pane/terminal-native-only-shortcut.ts index 12d04a0abe8..5ae1c84bf2c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-native-only-shortcut.ts +++ b/src/renderer/src/components/terminal-pane/terminal-native-only-shortcut.ts @@ -1,6 +1,7 @@ type TerminalNativeOnlyShortcutKeyEvent = { key: string code?: string + repeat?: boolean } type TerminalNativeOnlyShortcutCompanionEvent = TerminalNativeOnlyShortcutKeyEvent & { @@ -107,7 +108,9 @@ export function createTerminalNativeOnlyShortcutTracker(): TerminalNativeOnlySho prepareKeyDown: (event) => { // Why: replace a lost-keyup entry for this key without disarming other // held native-only keys during normal key rollover. - pendingKeys.delete(getTerminalShortcutKeyIdentity(event)) + if (!event.repeat) { + pendingKeys.delete(getTerminalShortcutKeyIdentity(event)) + } }, armKeyDown: (event) => { pendingKeys.set(getTerminalShortcutKeyIdentity(event), event.key) diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts index 6ab00a13806..9fba9efb447 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts @@ -53,6 +53,7 @@ const PUNCTUATION_CODE_MAP: Record = { export type TerminalShortcutAction = | { type: 'copySelection' } + | { type: 'selectAll' } | { type: 'toggleSearch' } | { type: 'clearActivePane' } | { type: 'focusPane'; direction: 'next' | 'previous' } @@ -118,6 +119,12 @@ export function resolveTerminalShortcutAction( return { type: 'switchInputSource' } } + // Why: held select-all keydowns must remain claimed until keyup so Kitty + // event reporting cannot encode their repeat or release into the PTY. + if (keybindingMatchesAction('terminal.selectAll', event, platform, keybindings)) { + return { type: 'selectAll' } + } + if (!event.repeat) { if (keybindingMatchesAction('terminal.copySelection', event, platform, keybindings)) { return { type: 'copySelection' } diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-select-all.test.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-select-all.test.ts new file mode 100644 index 00000000000..7a15ee1f444 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-select-all.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { + resolveTerminalShortcutAction, + type TerminalShortcutEvent +} from './terminal-shortcut-policy' + +function event(overrides: Partial): TerminalShortcutEvent { + return { + key: 'a', + code: 'KeyA', + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + repeat: false, + ...overrides + } +} + +describe('terminal select-all shortcut', () => { + it('uses Cmd+A on macOS', () => { + expect(resolveTerminalShortcutAction(event({ metaKey: true }), true)).toEqual({ + type: 'selectAll' + }) + expect(resolveTerminalShortcutAction(event({ metaKey: true, repeat: true }), true)).toEqual({ + type: 'selectAll' + }) + }) + + it('uses Ctrl+Shift+A without stealing bare Ctrl+A off macOS', () => { + expect(resolveTerminalShortcutAction(event({ ctrlKey: true, shiftKey: true }), false)).toEqual({ + type: 'selectAll' + }) + expect(resolveTerminalShortcutAction(event({ ctrlKey: true }), false)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index 207de0eafdc..c8264945744 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -89,6 +89,7 @@ type TerminalMenuState = { onContextMenuCapture: (event: React.MouseEvent) => void onPaneTitleContextMenu: (event: React.MouseEvent, paneId: number) => void onCopy: () => Promise + onSelectAll: () => void onCopyTerminalId: () => Promise onCopyPaneId: () => Promise onPaste: () => Promise @@ -172,6 +173,14 @@ export function useTerminalPaneContextMenu({ }) } + const onSelectAll = (): void => { + const pane = resolveMenuPane() + if (pane) { + pane.terminal.selectAll() + pane.terminal.focus() + } + } + const onCopyPaneId = async (): Promise => { const pane = resolveMenuPane() if (!pane) { @@ -588,6 +597,7 @@ export function useTerminalPaneContextMenu({ onContextMenuCapture, onPaneTitleContextMenu, onCopy, + onSelectAll, onCopyTerminalId, onCopyPaneId, onPaste, diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts index 55e60cc5367..502067c231c 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts @@ -44,8 +44,8 @@ describe('shouldBypassXtermKeyboardEvent — macOS', () => { // Why: this policy is narrowly scoped to clipboard chords. Cmd+F, Cmd+D, // Cmd+K, Cmd+W, Cmd+Arrow, Cmd+Backspace are handled in keyboard-handlers.ts // with stopImmediatePropagation before xterm's textarea listener fires. - // Cmd+A flows through xterm's legacy evaluator which correctly produces - // type=1 (selectAll), so we must not swallow it here. + // Cmd+A is claimed by keyboard-handlers.ts before xterm, including when + // Kitty keyboard reporting replaces xterm's legacy select-all evaluator. const cases = [ event({ key: 'a', code: 'KeyA', metaKey: true }), event({ key: 't', code: 'KeyT', metaKey: true }) diff --git a/src/renderer/src/hooks/useAppMenuSelectionActions.test.tsx b/src/renderer/src/hooks/useAppMenuSelectionActions.test.tsx new file mode 100644 index 00000000000..771f1da6885 --- /dev/null +++ b/src/renderer/src/hooks/useAppMenuSelectionActions.test.tsx @@ -0,0 +1,56 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { APP_MENU_SELECTION_ACTION_EVENT } from '@/lib/app-menu-selection-actions' +import { useAppMenuSelectionActions } from './useAppMenuSelectionActions' + +let listener: ((action: 'copy' | 'select-all') => void) | null = null +const performNativeSelectionAction = vi.fn() + +function Harness(): null { + useAppMenuSelectionActions() + return null +} + +beforeEach(() => { + listener = null + performNativeSelectionAction.mockReset() + Object.defineProperty(window, 'api', { + configurable: true, + value: { + ui: { + onAppMenuSelectionAction: vi.fn((callback) => { + listener = callback + return () => { + listener = null + } + }), + performNativeSelectionAction + } + } + }) +}) + +afterEach(() => cleanup()) + +describe('useAppMenuSelectionActions', () => { + it('falls back to native selection when no Orca surface claims the action', () => { + render() + + act(() => listener?.('select-all')) + + expect(performNativeSelectionAction).toHaveBeenCalledWith('select-all') + }) + + it('does not run native selection after a terminal claims the action', () => { + const claim = (event: Event): void => event.preventDefault() + window.addEventListener(APP_MENU_SELECTION_ACTION_EVENT, claim) + render() + + act(() => listener?.('copy')) + + window.removeEventListener(APP_MENU_SELECTION_ACTION_EVENT, claim) + expect(performNativeSelectionAction).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/useAppMenuSelectionActions.ts b/src/renderer/src/hooks/useAppMenuSelectionActions.ts new file mode 100644 index 00000000000..36c6d36ad0f --- /dev/null +++ b/src/renderer/src/hooks/useAppMenuSelectionActions.ts @@ -0,0 +1,15 @@ +import { useEffect } from 'react' +import { + dispatchAppMenuSelectionAction, + type AppMenuSelectionAction +} from '@/lib/app-menu-selection-actions' + +export function useAppMenuSelectionActions(): void { + useEffect(() => { + return window.api.ui.onAppMenuSelectionAction((action: AppMenuSelectionAction) => { + if (!dispatchAppMenuSelectionAction(action)) { + window.api.ui.performNativeSelectionAction(action) + } + }) + }, []) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1b90d246858..a1eedc20694 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -91,7 +91,9 @@ "openWorktreePalette": "Open Worktree Palette", "window": "Window", "help": "Help", - "paste": "Paste" + "paste": "Paste", + "copy": "Copy", + "selectAll": "Select All" }, "tray": { "openOrca": "Open Orca", @@ -2817,6 +2819,7 @@ "ec85df5914": "Quick Commands", "0a917b591a": "Paste", "f3eeb1de13": "Copy", + "selectAll": "Select All", "c2f0b72b8d": "Insert", "925f49f210": "Expand Pane", "df766809e0": "Collapse Pane", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d3cd1a3f053..dc1024c8b27 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -64,7 +64,9 @@ "openWorktreePalette": "Abrir paleta de worktrees", "window": "Ventana", "help": "Ayuda", - "paste": "Pegar" + "paste": "Pegar", + "copy": "Copiar", + "selectAll": "Seleccionar todo" }, "tray": { "openOrca": "Abrir Orca", @@ -2717,6 +2719,7 @@ "ec85df5914": "Comandos rápidos", "0a917b591a": "Pegar", "f3eeb1de13": "Copiar", + "selectAll": "Seleccionar todo", "c2f0b72b8d": "Insertar", "925f49f210": "Expandir panel", "df766809e0": "Contraer panel", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1ae13a69d41..3d7991b1475 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -64,7 +64,9 @@ "openWorktreePalette": "ワークツリーパレットを開く", "window": "ウィンドウ", "help": "ヘルプ", - "paste": "貼り付け" + "paste": "貼り付け", + "copy": "コピー", + "selectAll": "すべて選択" }, "tray": { "openOrca": "Orca を開く", @@ -2717,6 +2719,7 @@ "ec85df5914": "クイックコマンド", "0a917b591a": "貼り付け", "f3eeb1de13": "コピー", + "selectAll": "すべて選択", "c2f0b72b8d": "入れる", "925f49f210": "ペインを展開する", "df766809e0": "ペインを折りたたむ", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 90fdafbc5c8..c04d93c1629 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -64,7 +64,9 @@ "openWorktreePalette": "워크트리 팔레트 열기", "window": "창", "help": "도움말", - "paste": "붙여넣기" + "paste": "붙여넣기", + "copy": "복사", + "selectAll": "모두 선택" }, "tray": { "openOrca": "Orca 열기", @@ -2717,6 +2719,7 @@ "ec85df5914": "빠른 명령어", "0a917b591a": "붙여넣기", "f3eeb1de13": "복사", + "selectAll": "모두 선택", "c2f0b72b8d": "삽입", "925f49f210": "창 확장", "df766809e0": "창 축소", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 01dddfa391a..85b4cd051ba 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -64,7 +64,9 @@ "openWorktreePalette": "打开工作树面板", "window": "窗口", "help": "帮助", - "paste": "粘贴" + "paste": "粘贴", + "copy": "复制", + "selectAll": "全选" }, "tray": { "openOrca": "打开 Orca", @@ -2729,6 +2731,7 @@ "ec85df5914": "快捷命令", "0a917b591a": "粘贴", "f3eeb1de13": "复制", + "selectAll": "全选", "c2f0b72b8d": "插入", "925f49f210": "展开窗格", "df766809e0": "折叠窗格", diff --git a/src/renderer/src/lib/app-menu-selection-actions.test.ts b/src/renderer/src/lib/app-menu-selection-actions.test.ts new file mode 100644 index 00000000000..488702a72a6 --- /dev/null +++ b/src/renderer/src/lib/app-menu-selection-actions.test.ts @@ -0,0 +1,22 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from 'vitest' +import { + APP_MENU_SELECTION_ACTION_EVENT, + dispatchAppMenuSelectionAction +} from './app-menu-selection-actions' + +describe('app menu selection actions', () => { + it.each(['copy', 'select-all'] as const)('reports whether %s has an owned target', (action) => { + const handler = (event: Event): void => event.preventDefault() + window.addEventListener(APP_MENU_SELECTION_ACTION_EVENT, handler) + + expect(dispatchAppMenuSelectionAction(action)).toBe(true) + + window.removeEventListener(APP_MENU_SELECTION_ACTION_EVENT, handler) + }) + + it('leaves unowned actions available for native fallback', () => { + expect(dispatchAppMenuSelectionAction('copy')).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/app-menu-selection-actions.ts b/src/renderer/src/lib/app-menu-selection-actions.ts new file mode 100644 index 00000000000..34f9086e4d7 --- /dev/null +++ b/src/renderer/src/lib/app-menu-selection-actions.ts @@ -0,0 +1,15 @@ +export const APP_MENU_SELECTION_ACTION_EVENT = 'orca-app-menu-selection-action' + +export type AppMenuSelectionAction = 'copy' | 'select-all' + +export function dispatchAppMenuSelectionAction( + action: AppMenuSelectionAction, + target: Window = window +): boolean { + const event = new CustomEvent(APP_MENU_SELECTION_ACTION_EVENT, { + detail: action, + cancelable: true + }) + target.dispatchEvent(event) + return event.defaultPrevented +} diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 5450f52a6fe..6f049d237c8 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2696,8 +2696,12 @@ function createWebUiApi(): NonNullable['ui']> { performNativePaste: () => { document.execCommand?.('paste') }, + performNativeSelectionAction: (action) => { + document.execCommand?.(action === 'copy' ? 'copy' : 'selectAll') + }, onExportPdfRequested: () => noopUnsubscribe, onAppMenuPaste: () => noopUnsubscribe, + onAppMenuSelectionAction: () => noopUnsubscribe, onEditableContextPaste: () => noopUnsubscribe, getZoomLevel: () => zoomLevel, setZoomLevel: (level) => { diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index edf2bfa22c6..59affe46eb8 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -1111,13 +1111,64 @@ describe('keybindings', () => { ).toBe(true) }) - it('keeps the existing terminal paste defaults on Windows and Linux', () => { + it('keeps terminal clipboard shortcuts platform-native without stealing bare Ctrl+A', () => { + expect(getEffectiveKeybindingsForAction('terminal.copySelection', 'darwin')).toEqual(['Mod+C']) + expect(getEffectiveKeybindingsForAction('terminal.copySelection', 'linux')).toEqual([ + 'Ctrl+Shift+C', + 'Ctrl+C' + ]) + expect(getEffectiveKeybindingsForAction('terminal.selectAll', 'darwin')).toEqual(['Mod+A']) + expect(getEffectiveKeybindingsForAction('terminal.selectAll', 'linux')).toEqual([ + 'Ctrl+Shift+A' + ]) expect(getEffectiveKeybindingsForAction('terminal.paste', 'darwin')).toEqual(['Mod+V']) expect(getEffectiveKeybindingsForAction('terminal.paste', 'linux')).toEqual([ 'Ctrl+V', 'Ctrl+Shift+V', 'Shift+Insert' ]) + expect( + keybindingMatchesAction( + 'terminal.copySelection', + { key: 'c', code: 'KeyC', control: false, meta: true, alt: false, shift: false }, + 'darwin' + ) + ).toBe(true) + expect( + keybindingMatchesAction( + 'terminal.copySelection', + { key: 'c', code: 'KeyC', control: true, meta: false, alt: false, shift: true }, + 'linux' + ) + ).toBe(true) + expect( + keybindingMatchesAction( + 'terminal.copySelection', + { key: 'c', code: 'KeyC', control: true, meta: false, alt: false, shift: false }, + 'linux' + ) + ).toBe(true) + expect( + keybindingMatchesAction( + 'terminal.selectAll', + { key: 'a', code: 'KeyA', control: false, meta: true, alt: false, shift: false }, + 'darwin' + ) + ).toBe(true) + expect( + keybindingMatchesAction( + 'terminal.selectAll', + { key: 'a', code: 'KeyA', control: true, meta: false, alt: false, shift: true }, + 'linux' + ) + ).toBe(true) + expect( + keybindingMatchesAction( + 'terminal.selectAll', + { key: 'a', code: 'KeyA', control: true, meta: false, alt: false, shift: false }, + 'linux' + ) + ).toBe(false) expect( keybindingMatchesAction( 'terminal.paste', diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index 4fd980aa402..6a3b2ec5b8c 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -101,6 +101,7 @@ export type KeybindingActionId = | 'fileExplorer.delete' | 'settings.search' | 'terminal.copySelection' + | 'terminal.selectAll' | 'terminal.paste' | 'terminal.search' | 'terminal.clear' @@ -939,7 +940,23 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ group: 'Terminal Panes', scope: 'terminal', searchKeywords: ['shortcut', 'terminal', 'copy', 'selection'], - defaultBindings: platformBindings(['Mod+Shift+C']) + defaultBindings: { + darwin: ['Mod+C'], + linux: ['Ctrl+Shift+C', 'Ctrl+C'], + win32: ['Ctrl+Shift+C', 'Ctrl+C'] + } + }, + { + id: 'terminal.selectAll', + title: 'Select all terminal text', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'terminal', 'select', 'all'], + defaultBindings: { + darwin: ['Mod+A'], + linux: ['Ctrl+Shift+A'], + win32: ['Ctrl+Shift+A'] + } }, { id: 'terminal.paste',