fix(dashboard-popout): harden terminal clipboard routing

This commit is contained in:
Brennan Benson
2026-07-21 13:03:54 -07:00
parent 55b50f813e
commit 8509ca9e32
2 changed files with 198 additions and 20 deletions
@@ -3,6 +3,14 @@
import '@testing-library/jest-dom/vitest'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
TERMINAL_PASTE_CHUNK_MAX_BYTES,
TERMINAL_PASTE_DIRECT_MAX_BYTES
} from '@/components/terminal-pane/terminal-paste-limits'
import {
BRACKETED_PASTE_END,
BRACKETED_PASTE_START
} from '@/components/terminal-pane/terminal-bracketed-paste'
const terminalHarness = vi.hoisted(() => ({
instances: [] as {
@@ -13,6 +21,7 @@ const terminalHarness = vi.hoisted(() => ({
resize: ReturnType<typeof vi.fn>
reset: ReturnType<typeof vi.fn>
paste: ReturnType<typeof vi.fn>
modes: { bracketedPasteMode: boolean }
selectionText: string
customKeyHandler: ((event: KeyboardEvent) => boolean) | null
}[],
@@ -38,7 +47,11 @@ vi.mock('@xterm/xterm', () => ({
dispose = vi.fn()
resize = vi.fn()
reset = vi.fn()
paste = vi.fn()
modes = { bracketedPasteMode: false }
paste = vi.fn((data: string) => {
terminalHarness.userInputListener?.()
this.onDataListener?.(data)
})
getSelection = vi.fn(() => this.selectionText)
attachCustomKeyEventHandler = vi.fn((handler: (event: KeyboardEvent) => boolean) => {
this.customKeyHandler = handler
@@ -75,7 +88,7 @@ vi.mock('@/store', () => {
import { AgentTerminalPreview } from './AgentTerminalPreview'
describe('AgentTerminalPreview', () => {
const input = vi.fn(async () => true)
const input = vi.fn(async (_ptyId: string, _data: string) => true)
const ack = vi.fn(async () => {})
const unsubscribe = vi.fn(async () => {})
const connect = vi.fn()
@@ -153,14 +166,24 @@ describe('AgentTerminalPreview', () => {
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 })
const keydown = new KeyboardEvent('keydown', {
key: 'C',
code: 'KeyC',
ctrlKey: true,
shiftKey: true,
cancelable: true
})
const handled = terminal.customKeyHandler!(keydown)
const keyupHandled = terminal.customKeyHandler!(
new KeyboardEvent('keyup', { key: 'C', code: 'KeyC', ctrlKey: true, shiftKey: true })
)
expect(handled).toBe(false)
expect(keyupHandled).toBe(false)
expect(keydown.defaultPrevented).toBe(true)
expect(writeClipboardText).toHaveBeenCalledWith('selected text')
})
it('lets the copy chord fall through to xterm when nothing is selected', async () => {
it('keeps an empty copy chord from leaking terminal input', async () => {
render(<AgentTerminalPreview ptyId="pty-1" />)
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
const terminal = terminalHarness.instances[0]!
@@ -169,7 +192,7 @@ describe('AgentTerminalPreview', () => {
const handled = terminal.customKeyHandler!(
new KeyboardEvent('keydown', { key: 'C', code: 'KeyC', ctrlKey: true, shiftKey: true })
)
expect(handled).toBe(true)
expect(handled).toBe(false)
expect(writeClipboardText).not.toHaveBeenCalled()
})
@@ -186,6 +209,7 @@ describe('AgentTerminalPreview', () => {
act(() => emitAppMenuPaste?.())
await waitFor(() => expect(terminal.paste).toHaveBeenCalledWith('clip-text'))
expect(input).toHaveBeenCalledWith('pty-1', 'clip-text')
})
it('ignores the app-menu paste signal when focus is outside the preview', async () => {
@@ -199,10 +223,14 @@ describe('AgentTerminalPreview', () => {
})
it('leaves plain Ctrl+V to the Edit-menu accelerator but handles the shifted paste chord', async () => {
render(<AgentTerminalPreview ptyId="pty-1" />)
const view = 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 host = view.container.querySelector<HTMLElement>('.origin-bottom-left')!
const terminalInput = document.createElement('input')
host.appendChild(terminalInput)
terminalInput.focus()
const plain = terminal.customKeyHandler!(
new KeyboardEvent('keydown', { key: 'v', code: 'KeyV', ctrlKey: true })
@@ -210,12 +238,105 @@ describe('AgentTerminalPreview', () => {
expect(plain).toBe(true)
expect(readClipboardText).not.toHaveBeenCalled()
const shifted = terminal.customKeyHandler!(
new KeyboardEvent('keydown', { key: 'V', code: 'KeyV', ctrlKey: true, shiftKey: true })
const shiftedEvent = new KeyboardEvent('keydown', {
key: 'V',
code: 'KeyV',
ctrlKey: true,
shiftKey: true,
cancelable: true
})
const shifted = terminal.customKeyHandler!(shiftedEvent)
const repeated = terminal.customKeyHandler!(
new KeyboardEvent('keydown', {
key: 'V',
code: 'KeyV',
ctrlKey: true,
shiftKey: true,
repeat: true
})
)
expect(shifted).toBe(false)
expect(repeated).toBe(false)
expect(shiftedEvent.defaultPrevented).toBe(true)
await waitFor(() => expect(terminal.paste).toHaveBeenCalledWith('clip-text'))
expect(readClipboardText).toHaveBeenCalledTimes(1)
expect(
terminal.customKeyHandler!(
new KeyboardEvent('keyup', { key: 'V', code: 'KeyV', ctrlKey: true, shiftKey: true })
)
).toBe(false)
})
it('cancels an async paste when the preview loses focus', async () => {
let resolveClipboard!: (text: string) => void
readClipboardText.mockReturnValueOnce(
new Promise((resolve) => {
resolveClipboard = resolve
})
)
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 terminalInput = document.createElement('input')
const outsideInput = document.createElement('input')
host.appendChild(terminalInput)
view.container.appendChild(outsideInput)
terminalInput.focus()
act(() => emitAppMenuPaste?.())
outsideInput.focus()
await act(async () => resolveClipboard('stale text'))
expect(terminal.paste).not.toHaveBeenCalled()
expect(input).not.toHaveBeenCalled()
})
it('streams large pastes as bounded IPC payloads instead of one renderer-blocking write', async () => {
const largePaste = 'x'.repeat(TERMINAL_PASTE_DIRECT_MAX_BYTES + 1)
readClipboardText.mockResolvedValueOnce(largePaste)
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 terminalInput = document.createElement('input')
host.appendChild(terminalInput)
terminalInput.focus()
act(() => emitAppMenuPaste?.())
const expectedChunks = Math.ceil(largePaste.length / TERMINAL_PASTE_CHUNK_MAX_BYTES)
await waitFor(() => expect(input).toHaveBeenCalledTimes(expectedChunks))
const payloads = input.mock.calls.map(([, data]) => data as string)
expect(terminal.paste).not.toHaveBeenCalled()
expect(payloads.join('')).toBe(largePaste)
expect(payloads.every((payload) => payload.length <= TERMINAL_PASTE_CHUNK_MAX_BYTES)).toBe(true)
})
it('closes a bracketed large paste when focus changes between chunks', async () => {
readClipboardText.mockResolvedValueOnce('x'.repeat(TERMINAL_PASTE_DIRECT_MAX_BYTES + 1))
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
const terminal = terminalHarness.instances[0]!
terminal.modes.bracketedPasteMode = true
const host = view.container.querySelector<HTMLElement>('.origin-bottom-left')!
const terminalInput = document.createElement('input')
const outsideInput = document.createElement('input')
host.appendChild(terminalInput)
view.container.appendChild(outsideInput)
terminalInput.focus()
input.mockImplementationOnce(async () => {
outsideInput.focus()
return true
})
act(() => emitAppMenuPaste?.())
await waitFor(() => expect(input).toHaveBeenCalledTimes(2))
expect(input.mock.calls.map(([, data]) => data)).toEqual([
BRACKETED_PASTE_START,
BRACKETED_PASTE_END
])
})
it('keeps the existing terminal visible while a resync snapshot is captured', async () => {
@@ -4,6 +4,11 @@ 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 {
executeTerminalPastePlan,
planTerminalPasteWithYield
} from '@/components/terminal-pane/terminal-paste-coordinator'
import { resolveTerminalPasteRuntime } from '@/components/terminal-pane/terminal-paste-runtime'
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'
@@ -118,17 +123,50 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
})
}
const pasteClipboardText = async (): Promise<void> => {
const pasteClipboardText = async (
activeElementAtDispatch: Element | null,
source: 'keyboard' | 'app-menu'
): 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 pasteTerminal = terminal
if (!pasteTerminal || !text) {
return
}
const targetIsCurrent = (): boolean =>
!disposed &&
terminal === pasteTerminal &&
activeElementAtDispatch !== null &&
document.activeElement === activeElementAtDispatch &&
container.contains(activeElementAtDispatch)
if (!targetIsCurrent()) {
return
}
const platform = getShortcutPlatform()
const plan = await planTerminalPasteWithYield({
text,
source,
target: {
kind: 'terminal',
paneId: 0,
leafId: ptyId,
ptyId,
runtime: resolveTerminalPasteRuntime({ platform, ptyId })
},
terminalBracketedPasteMode: pasteTerminal.modes.bracketedPasteMode
})
await executeTerminalPastePlan(plan, {
// Why: stream large pastes so the renderer never emits one huge IPC payload.
pasteText: (pasteText) => pasteTerminal.paste(pasteText),
writePty: (data) => window.api.terminalPreview.input(ptyId, data),
isTargetCurrent: targetIsCurrent,
// Why: if focus changes mid-bracketed paste, the closing marker must still reach the live PTY.
canContinue: () => true
})
}
const installClipboardShortcuts = (): void => {
@@ -136,18 +174,33 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
return
}
const platform = getShortcutPlatform()
const consumedClipboardKeys = new Set<string>()
const consumeEvent = (event: KeyboardEvent): false => {
event.preventDefault()
event.stopPropagation()
return false
}
terminal.attachCustomKeyEventHandler((event) => {
if (event.type !== 'keydown') {
const keyIdentity = event.code || event.key
if (consumedClipboardKeys.has(keyIdentity)) {
if (event.type === 'keyup') {
consumedClipboardKeys.delete(keyIdentity)
}
return consumeEvent(event)
}
return true
}
const keybindings = useAppStore.getState().keybindings
if (keybindingMatchesAction('terminal.copySelection', event, platform, keybindings)) {
const keyIdentity = event.code || event.key
const firstKeydown = !consumedClipboardKeys.has(keyIdentity)
consumedClipboardKeys.add(keyIdentity)
const selection = terminal?.getSelection()
if (!selection) {
return true
if (firstKeydown && selection) {
void window.api.ui.writeClipboardText(selection).catch(() => undefined)
}
void window.api.ui.writeClipboardText(selection).catch(() => undefined)
return false
return consumeEvent(event)
}
// 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 =
@@ -161,8 +214,12 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
!isMenuPasteChord &&
keybindingMatchesAction('terminal.paste', event, platform, keybindings)
) {
void pasteClipboardText()
return false
const keyIdentity = event.code || event.key
if (!consumedClipboardKeys.has(keyIdentity)) {
consumedClipboardKeys.add(keyIdentity)
void pasteClipboardText(document.activeElement, 'keyboard')
}
return consumeEvent(event)
}
return true
})
@@ -295,7 +352,7 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
const offAppMenuPaste = window.api.ui.onAppMenuPaste(() => {
const active = document.activeElement
if (active && container.contains(active)) {
void pasteClipboardText()
void pasteClipboardText(active, 'app-menu')
}
})