mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
fix(dashboard-popout): wire terminal copy/paste in the popped-out window
The Edit menu's Paste is a custom item that routes Cmd/Ctrl+V to the focused window as ui:appMenuPaste, and only the main window's React root listens for it — the popout dropped it, so paste silently did nothing. The copy chord similarly resolves in the main window's before-input-event handler, which the popout window never registers; the menu's role:'copy' no-ops on xterm's empty hidden textarea. AgentTerminalPreview now subscribes to onAppMenuPaste (guarded on focus inside the preview) and pastes via terminal.paste(), which xterm flags as user input so the existing preview->PTY routing and main-process input limits apply. A custom key handler honors the terminal.copySelection and terminal.paste keybindings, skipping plain Mod+V since the menu accelerator owns that chord (handling it twice would paste double). The popout bootstrap fetches keybinding overrides so custom bindings apply.
This commit is contained in:
@@ -12,6 +12,9 @@ const terminalHarness = vi.hoisted(() => ({
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
resize: ReturnType<typeof vi.fn>
|
||||
reset: ReturnType<typeof vi.fn>
|
||||
paste: ReturnType<typeof vi.fn>
|
||||
selectionText: string
|
||||
customKeyHandler: ((event: KeyboardEvent) => boolean) | null
|
||||
}[],
|
||||
userInputListener: null as (() => void) | null,
|
||||
userInputDispose: vi.fn()
|
||||
@@ -23,6 +26,8 @@ vi.mock('@xterm/xterm', () => ({
|
||||
buffer = { active: { cursorY: 0 } }
|
||||
writeCallbacks: (() => void)[] = []
|
||||
onDataListener: ((data: string) => void) | null = null
|
||||
customKeyHandler: ((event: KeyboardEvent) => boolean) | null = null
|
||||
selectionText = ''
|
||||
write = vi.fn((_data: string, callback?: () => void) => {
|
||||
if (callback) {
|
||||
this.writeCallbacks.push(callback)
|
||||
@@ -33,6 +38,11 @@ vi.mock('@xterm/xterm', () => ({
|
||||
dispose = vi.fn()
|
||||
resize = vi.fn()
|
||||
reset = vi.fn()
|
||||
paste = vi.fn()
|
||||
getSelection = vi.fn(() => this.selectionText)
|
||||
attachCustomKeyEventHandler = vi.fn((handler: (event: KeyboardEvent) => boolean) => {
|
||||
this.customKeyHandler = handler
|
||||
})
|
||||
onData = vi.fn((listener: (data: string) => void) => {
|
||||
this.onDataListener = listener
|
||||
return { dispose: vi.fn() }
|
||||
@@ -55,9 +65,12 @@ vi.mock('@/components/terminal-pane/terminal-user-input-signal', () => ({
|
||||
vi.mock('@/components/terminal-pane/use-system-prefers-dark', () => ({
|
||||
useSystemPrefersDark: () => false
|
||||
}))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: { settings: null }) => unknown) => selector({ settings: null })
|
||||
}))
|
||||
vi.mock('@/store', () => {
|
||||
const state = { settings: null, keybindings: {} }
|
||||
const useAppStore = (selector: (s: typeof state) => unknown): unknown => selector(state)
|
||||
useAppStore.getState = (): typeof state => state
|
||||
return { useAppStore }
|
||||
})
|
||||
|
||||
import { AgentTerminalPreview } from './AgentTerminalPreview'
|
||||
|
||||
@@ -66,16 +79,21 @@ describe('AgentTerminalPreview', () => {
|
||||
const ack = vi.fn(async () => {})
|
||||
const unsubscribe = vi.fn(async () => {})
|
||||
const connect = vi.fn()
|
||||
const readClipboardText = vi.fn(async () => 'clip-text')
|
||||
const writeClipboardText = vi.fn(async () => {})
|
||||
let emitData: ((payload: unknown) => void) | null
|
||||
let emitAppMenuPaste: (() => void) | null
|
||||
|
||||
beforeEach(() => {
|
||||
terminalHarness.instances.length = 0
|
||||
terminalHarness.userInputListener = null
|
||||
emitData = null
|
||||
emitAppMenuPaste = null
|
||||
connect.mockResolvedValue({
|
||||
snapshot: { data: '', cols: 80, rows: 24, seq: 1 },
|
||||
replay: []
|
||||
})
|
||||
readClipboardText.mockResolvedValue('clip-text')
|
||||
Object.assign(window, {
|
||||
api: {
|
||||
terminalPreview: {
|
||||
@@ -87,6 +105,14 @@ describe('AgentTerminalPreview', () => {
|
||||
emitData = listener
|
||||
return vi.fn()
|
||||
}
|
||||
},
|
||||
ui: {
|
||||
readClipboardText,
|
||||
writeClipboardText,
|
||||
onAppMenuPaste: (listener: () => void) => {
|
||||
emitAppMenuPaste = listener
|
||||
return vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -120,6 +146,78 @@ describe('AgentTerminalPreview', () => {
|
||||
expect(ack).toHaveBeenCalledWith('pty-1', 4)
|
||||
})
|
||||
|
||||
it('copies the terminal selection on the copy chord and blocks xterm handling', async () => {
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
|
||||
|
||||
terminal.selectionText = 'selected text'
|
||||
const handled = terminal.customKeyHandler!(
|
||||
new KeyboardEvent('keydown', { key: 'C', code: 'KeyC', ctrlKey: true, shiftKey: true })
|
||||
)
|
||||
expect(handled).toBe(false)
|
||||
expect(writeClipboardText).toHaveBeenCalledWith('selected text')
|
||||
})
|
||||
|
||||
it('lets the copy chord fall through to xterm when nothing is selected', async () => {
|
||||
render(<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, shiftKey: true })
|
||||
)
|
||||
expect(handled).toBe(true)
|
||||
expect(writeClipboardText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pastes clipboard text on the app-menu paste signal while the preview owns focus', async () => {
|
||||
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
expect(emitAppMenuPaste).not.toBeNull()
|
||||
|
||||
const host = view.container.querySelector<HTMLElement>('.origin-bottom-left')!
|
||||
const focusTarget = document.createElement('input')
|
||||
host.appendChild(focusTarget)
|
||||
focusTarget.focus()
|
||||
|
||||
act(() => emitAppMenuPaste?.())
|
||||
await waitFor(() => expect(terminal.paste).toHaveBeenCalledWith('clip-text'))
|
||||
})
|
||||
|
||||
it('ignores the app-menu paste signal when focus is outside the preview', async () => {
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
expect(emitAppMenuPaste).not.toBeNull()
|
||||
|
||||
await act(async () => emitAppMenuPaste?.())
|
||||
expect(readClipboardText).not.toHaveBeenCalled()
|
||||
expect(terminalHarness.instances[0]!.paste).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves plain Ctrl+V to the Edit-menu accelerator but handles the shifted paste chord', async () => {
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
|
||||
|
||||
const plain = terminal.customKeyHandler!(
|
||||
new KeyboardEvent('keydown', { key: 'v', code: 'KeyV', ctrlKey: true })
|
||||
)
|
||||
expect(plain).toBe(true)
|
||||
expect(readClipboardText).not.toHaveBeenCalled()
|
||||
|
||||
const shifted = terminal.customKeyHandler!(
|
||||
new KeyboardEvent('keydown', { key: 'V', code: 'KeyV', ctrlKey: true, shiftKey: true })
|
||||
)
|
||||
expect(shifted).toBe(false)
|
||||
await waitFor(() => expect(terminal.paste).toHaveBeenCalledWith('clip-text'))
|
||||
expect(readClipboardText).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the existing terminal visible while a resync snapshot is captured', async () => {
|
||||
let resolveRefresh!: (value: {
|
||||
snapshot: { data: string; cols: number; rows: number; seq: number }
|
||||
|
||||
@@ -2,11 +2,14 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { buildDefaultTerminalOptions } from '@/lib/pane-manager/pane-terminal-options'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { subscribeToTerminalUserInput } from '@/components/terminal-pane/terminal-user-input-signal'
|
||||
import { TERMINAL_PASTE_MAX_BYTES } from '@/components/terminal-pane/terminal-paste-limits'
|
||||
import { composeActiveTerminalTheme } from '@/components/terminal-pane/terminal-appearance'
|
||||
import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getBuiltinTheme, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
|
||||
import { keybindingMatchesAction } from '../../../../shared/keybindings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { TerminalPreviewDataPayload } from '../../../../shared/terminal-preview'
|
||||
@@ -115,6 +118,56 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
|
||||
})
|
||||
}
|
||||
|
||||
const pasteClipboardText = async (): Promise<void> => {
|
||||
let text: string
|
||||
try {
|
||||
text = await window.api.ui.readClipboardText({ maxBytes: TERMINAL_PASTE_MAX_BYTES })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!disposed && terminal && text) {
|
||||
// Why: paste() flows through onData as user input, so the existing PTY routing applies.
|
||||
terminal.paste(text)
|
||||
}
|
||||
}
|
||||
|
||||
const installClipboardShortcuts = (): void => {
|
||||
if (!terminal) {
|
||||
return
|
||||
}
|
||||
const platform = getShortcutPlatform()
|
||||
terminal.attachCustomKeyEventHandler((event) => {
|
||||
if (event.type !== 'keydown') {
|
||||
return true
|
||||
}
|
||||
const keybindings = useAppStore.getState().keybindings
|
||||
if (keybindingMatchesAction('terminal.copySelection', event, platform, keybindings)) {
|
||||
const selection = terminal?.getSelection()
|
||||
if (!selection) {
|
||||
return true
|
||||
}
|
||||
void window.api.ui.writeClipboardText(selection).catch(() => undefined)
|
||||
return false
|
||||
}
|
||||
// Why: plain Mod+V is the Edit-menu accelerator, which reaches this window as ui:appMenuPaste — matching it here too would paste twice.
|
||||
const isMenuPasteChord =
|
||||
(platform === 'darwin'
|
||||
? event.metaKey && !event.ctrlKey
|
||||
: event.ctrlKey && !event.metaKey) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
event.key.toLowerCase() === 'v'
|
||||
if (
|
||||
!isMenuPasteChord &&
|
||||
keybindingMatchesAction('terminal.paste', event, platform, keybindings)
|
||||
) {
|
||||
void pasteClipboardText()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
const installInputRouting = (): void => {
|
||||
if (!terminal) {
|
||||
return
|
||||
@@ -158,6 +211,7 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
|
||||
return
|
||||
}
|
||||
installInputRouting()
|
||||
installClipboardShortcuts()
|
||||
} else if (replaceExisting) {
|
||||
// Why: keep the old frame visible during capture, then atomically replace it once the authoritative snapshot arrives.
|
||||
terminal.resize(
|
||||
@@ -235,6 +289,16 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
|
||||
replayConnection(connection, replaceExisting, () => void setup(true))
|
||||
}
|
||||
|
||||
// Why: the popout has no TerminalPane/useAppMenuPaste, so the Edit menu's
|
||||
// Cmd/Ctrl+V (routed to the focused window as ui:appMenuPaste) would
|
||||
// otherwise be dropped and paste would silently do nothing here.
|
||||
const offAppMenuPaste = window.api.ui.onAppMenuPaste(() => {
|
||||
const active = document.activeElement
|
||||
if (active && container.contains(active)) {
|
||||
void pasteClipboardText()
|
||||
}
|
||||
})
|
||||
|
||||
offData = window.api.terminalPreview.onData((payload) => {
|
||||
if (payload.ptyId !== ptyId) {
|
||||
return
|
||||
@@ -253,6 +317,7 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer)
|
||||
}
|
||||
offAppMenuPaste()
|
||||
offData?.()
|
||||
userInputDisposable?.dispose()
|
||||
void window.api.terminalPreview.unsubscribe(ptyId)
|
||||
|
||||
@@ -59,6 +59,9 @@ function PopoutSettingsSync(): null {
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
// Why: the preview terminal's copy/paste chords honor user keybinding
|
||||
// overrides, which live in a separate file from settings.
|
||||
void useAppStore.getState().fetchKeybindings()
|
||||
const setSettings = (next: GlobalSettings): void => {
|
||||
if (!disposed) {
|
||||
useAppStore.setState({ settings: next })
|
||||
|
||||
Reference in New Issue
Block a user