mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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:')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
| {
|
||||
|
||||
@@ -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:
|
||||
| {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ペインを折りたたむ",
|
||||
|
||||
@@ -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": "창 축소",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user