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
This commit is contained in:
Brennan Benson
2026-08-09 16:52:13 -07:00
committed by GitHub
parent 5df2ddbc9c
commit 7aae88cd21
36 changed files with 702 additions and 31 deletions
+9 -1
View File
@@ -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)
+2 -1
View File
@@ -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)
+44
View File
@@ -71,6 +71,12 @@ function getNativePasteHandler():
return onMock.mock.calls.find(([channel]) => channel === 'ui:performNativePaste')?.[1]
}
function getNativeSelectionActionHandler():
| ((event: ReturnType<typeof makeUIEvent>, 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()
+20 -1
View File
@@ -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 {
+37
View File
@@ -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:')
}
}
}
}
+72 -1
View File
@@ -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)
+11 -2
View File
@@ -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
})
]
}
+2
View File
@@ -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<void>
writeClipboardImage: (dataUrl: string) => Promise<void>
performNativePaste: (options?: { mode?: 'paste' | 'paste-and-match-style' }) => void
performNativeSelectionAction: (action: 'copy' | 'select-all') => void
writeClipboardFile: (
args:
| {
+9
View File
@@ -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:
| {
+2
View File
@@ -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)
@@ -24,6 +24,7 @@ const terminalHarness = vi.hoisted(() => ({
input: ReturnType<typeof vi.fn>
scrollToTop: ReturnType<typeof vi.fn>
scrollToBottom: ReturnType<typeof vi.fn>
selectAll: ReturnType<typeof vi.fn>
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(<AgentTerminalPreview ptyId="pty-1" />)
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(<AgentTerminalPreview ptyId="pty-1" />)
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(<AgentTerminalPreview ptyId="pty-1" />)
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(<AgentTerminalPreview ptyId="pty-1" />)
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
const terminal = terminalHarness.instances[0]!
terminal.selectionText = 'selected text'
const host = view.container.querySelector<HTMLElement>('.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(<AgentTerminalPreview ptyId="pty-1" />)
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
const terminal = terminalHarness.instances[0]!
const host = view.container.querySelector<HTMLElement>('.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(<AgentTerminalPreview ptyId="pty-1" />)
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
@@ -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()
@@ -0,0 +1,43 @@
import type { Terminal } from '@xterm/xterm'
import { isEditableTarget } from '@/lib/editable-target'
type PreviewTerminalSelection = Pick<Terminal, 'getSelection' | 'selectAll'>
export function installPreviewTerminalAppMenuClipboard({
container,
getTerminal,
pasteClipboardText
}: {
container: HTMLElement
getTerminal: () => PreviewTerminalSelection | null
pasteClipboardText: (activeElementAtDispatch: Element | null, source: 'app-menu') => Promise<void>
}): () => 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()
}
}
@@ -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)
@@ -65,6 +65,7 @@ function renderMenu(overrides: Record<string, unknown> = {}): 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')
@@ -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')}
<DropdownMenuShortcut>{shortcuts.copy}</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onSelect={onSelectAll}>
<TextSelect />
{translate('auto.components.terminal.pane.TerminalContextMenu.selectAll', 'Select All')}
<DropdownMenuShortcut>{shortcuts.selectAll}</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onSelect={onPaste}>
<Clipboard />
{translate('auto.components.terminal.pane.TerminalContextMenu.0a917b591a', 'Paste')}
@@ -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<AppMenuSelectionAction>).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}
@@ -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) {
@@ -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' })
@@ -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)
@@ -53,6 +53,7 @@ const PUNCTUATION_CODE_MAP: Record<string, string> = {
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' }
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import {
resolveTerminalShortcutAction,
type TerminalShortcutEvent
} from './terminal-shortcut-policy'
function event(overrides: Partial<TerminalShortcutEvent>): 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()
})
})
@@ -89,6 +89,7 @@ type TerminalMenuState = {
onContextMenuCapture: (event: React.MouseEvent<HTMLDivElement>) => void
onPaneTitleContextMenu: (event: React.MouseEvent<HTMLElement>, paneId: number) => void
onCopy: () => Promise<void>
onSelectAll: () => void
onCopyTerminalId: () => Promise<void>
onCopyPaneId: () => Promise<void>
onPaste: () => Promise<void>
@@ -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<void> => {
const pane = resolveMenuPane()
if (!pane) {
@@ -588,6 +597,7 @@ export function useTerminalPaneContextMenu({
onContextMenuCapture,
onPaneTitleContextMenu,
onCopy,
onSelectAll,
onCopyTerminalId,
onCopyPaneId,
onPaste,
@@ -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 })
@@ -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(<Harness />)
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(<Harness />)
act(() => listener?.('copy'))
window.removeEventListener(APP_MENU_SELECTION_ACTION_EVENT, claim)
expect(performNativeSelectionAction).not.toHaveBeenCalled()
})
})
@@ -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)
}
})
}, [])
}
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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": "ペインを折りたたむ",
+4 -1
View File
@@ -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": "창 축소",
+4 -1
View File
@@ -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": "折叠窗格",
@@ -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)
})
})
@@ -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<AppMenuSelectionAction>(APP_MENU_SELECTION_ACTION_EVENT, {
detail: action,
cancelable: true
})
target.dispatchEvent(event)
return event.defaultPrevented
}
+4
View File
@@ -2696,8 +2696,12 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['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) => {
+52 -1
View File
@@ -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',
+18 -1
View File
@@ -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',