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:
Brennan Benson
2026-07-21 12:41:56 -07:00
parent b9b3d98d71
commit 55b50f813e
3 changed files with 169 additions and 3 deletions
@@ -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)
+3
View File
@@ -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 })