fix(dashboard-popout): wire terminal copy/paste in the popped-out window (#9765)

* 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.

* fix(dashboard-popout): harden terminal clipboard routing

* fix(dashboard-popout): authorize terminal clipboard text

* test(dashboard-popout): harden terminal paste coverage

* fix(terminal): normalize streamed paste newlines

* fix(dashboard-popout): forward macOS IME native-text commits in the preview terminal (#9771)

* fix(dashboard-popout): forward macOS IME native-text commits in the preview terminal

The preview terminal enables xterm's kitty keyboard protocol (via
buildDefaultTerminalOptions), whose encoder can encode and cancel a
printable keydown before Chromium commits the real IME/native text —
silently dropping macOS input-source commits and synthetic Unicode
injection. Main-window panes guard this with the IME native-text
forwarder; the pop-out preview never installed it.

Install the composition tracker + forwarder (macOS-only, mirroring
TerminalPane) and claim native-text key events at the top of the
preview's custom key handler so the committed glyph reaches the PTY via
terminal.input() and the existing user-input routing.

* fix(dashboard-popout): prewarm IME input source

* fix(skills): preserve released history across new tags (#9778)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

* fix(skills): record latest Linear release history (#9777)

---------

Co-authored-by: OrcaWin <alpha-eng@stably.ai>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>

---------

Co-authored-by: OrcaWin <alpha-eng@stably.ai>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
Brennan Benson
2026-07-21 14:51:34 -07:00
committed by GitHub
co-authored by OrcaWin OrcaWin
parent 15362fde16
commit 6458ebcf20
9 changed files with 698 additions and 32 deletions
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
handlers,
clipboardReadText,
clipboardWriteText,
clipboardReadImage,
clipboardWriteImage,
clipboardWriteBuffer,
isDashboardPopoutRenderer
} = vi.hoisted(() => ({
handlers: new Map<string, (...args: unknown[]) => unknown>(),
clipboardReadText: vi.fn(() => 'terminal clipboard text'),
clipboardWriteText: vi.fn(),
clipboardReadImage: vi.fn(),
clipboardWriteImage: vi.fn(),
clipboardWriteBuffer: vi.fn(),
isDashboardPopoutRenderer: vi.fn(() => true)
}))
vi.mock('electron', () => ({
app: { getPath: vi.fn(() => '/tmp') },
clipboard: {
readText: clipboardReadText,
readBuffer: vi.fn(),
writeText: clipboardWriteText,
readImage: clipboardReadImage,
writeImage: clipboardWriteImage,
writeBuffer: clipboardWriteBuffer
},
ipcMain: {
removeHandler: (channel: string) => handlers.delete(channel),
handle: (channel: string, handler: (...args: unknown[]) => unknown) =>
handlers.set(channel, handler)
},
nativeImage: { createFromBuffer: vi.fn() }
}))
vi.mock('./dashboard-popout-window', () => ({ isDashboardPopoutRenderer }))
vi.mock('./clipboard-remote-file-copy', () => ({
cleanupExpiredRemoteClipboardFiles: vi.fn(async () => undefined),
writeRemoteFileToClipboard: vi.fn()
}))
import {
registerClipboardHandlers,
setTrustedClipboardRendererWebContentsId
} from './clipboard-ipc-handlers'
const popoutEvent = {
sender: {
id: 42,
isDestroyed: () => false,
getType: () => 'window',
getURL: () => 'file:///popout.html'
}
}
describe('dashboard popout clipboard access', () => {
beforeEach(() => {
handlers.clear()
vi.clearAllMocks()
isDashboardPopoutRenderer.mockReturnValue(true)
clipboardReadText.mockReturnValue('terminal clipboard text')
setTrustedClipboardRendererWebContentsId(17)
registerClipboardHandlers({} as never)
})
it('allows terminal text copy and paste through the exact popout renderer', async () => {
await expect(handlers.get('clipboard:readText')?.(popoutEvent)).resolves.toBe(
'terminal clipboard text'
)
await expect(
handlers.get('clipboard:writeText')?.(popoutEvent, 'terminal selection')
).resolves.toBeUndefined()
expect(clipboardWriteText).toHaveBeenCalledWith('terminal selection')
})
it('does not extend popout authority to selection, image, file, or remote clipboard APIs', async () => {
await expect(handlers.get('clipboard:readSelectionText')?.(popoutEvent)).rejects.toThrow(
'Unauthorized clipboard IPC sender'
)
await expect(
handlers.get('clipboard:writeSelectionText')?.(popoutEvent, 'primary selection')
).rejects.toThrow('Unauthorized clipboard IPC sender')
await expect(handlers.get('clipboard:saveImageAsTempFile')?.(popoutEvent)).rejects.toThrow(
'Unauthorized clipboard IPC sender'
)
expect(() =>
handlers.get('clipboard:writeFile')?.(popoutEvent, {
filePath: '/tmp/copied-file.txt',
connectionId: 'ssh-secret'
})
).toThrow('Unauthorized clipboard IPC sender')
expect(() =>
handlers.get('clipboard:writeImage')?.(popoutEvent, 'data:image/png;base64,AAAA')
).toThrow('Unauthorized clipboard IPC sender')
expect(clipboardReadImage).not.toHaveBeenCalled()
expect(clipboardWriteImage).not.toHaveBeenCalled()
expect(clipboardWriteBuffer).not.toHaveBeenCalled()
})
it('still rejects unrelated renderer windows from text clipboard APIs', async () => {
isDashboardPopoutRenderer.mockReturnValue(false)
await expect(handlers.get('clipboard:readText')?.(popoutEvent)).rejects.toThrow(
'Unauthorized clipboard IPC sender'
)
await expect(handlers.get('clipboard:writeText')?.(popoutEvent, 'secret')).rejects.toThrow(
'Unauthorized clipboard IPC sender'
)
expect(clipboardReadText).not.toHaveBeenCalled()
expect(clipboardWriteText).not.toHaveBeenCalled()
})
})
@@ -127,6 +127,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
vi.mock('../ipc/runtime-environment-transport-routing', () => ({
callRuntimeEnvironment: callRuntimeEnvironmentMock
}))
vi.mock('./dashboard-popout-window', () => ({ isDashboardPopoutRenderer: () => false }))
import {
registerClipboardHandlers,
+11 -2
View File
@@ -35,6 +35,7 @@ import {
} from './clipboard-remote-file-copy'
import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload'
import { readWindowsClipboardImageFileAsPng } from './clipboard-windows-image-file'
import { isDashboardPopoutRenderer } from './dashboard-popout-window'
let trustedClipboardRendererWebContentsId: number | null = null
@@ -84,7 +85,7 @@ export function registerClipboardHandlers(store: Store): void {
void cleanupExpiredRemoteClipboardFiles()
ipcMain.handle('clipboard:readText', async (event, options?: ReadClipboardTextOptions) => {
assertTrustedClipboardSender(event)
assertTrustedClipboardTextSender(event)
return assertClipboardTextWithinLimitWithYield(clipboard.readText(), options)
})
ipcMain.handle(
@@ -155,7 +156,7 @@ export function registerClipboardHandlers(store: Store): void {
}
)
ipcMain.handle('clipboard:writeText', async (event, text: string) => {
assertTrustedClipboardSender(event)
assertTrustedClipboardTextSender(event)
return clipboard.writeText(await assertClipboardTextWriteWithinLimitWithYield(text))
})
ipcMain.handle('clipboard:writeSelectionText', async (event, text: string) => {
@@ -239,6 +240,14 @@ function assertTrustedClipboardSender(event: IpcMainInvokeEvent): void {
}
}
function assertTrustedClipboardTextSender(event: IpcMainInvokeEvent): void {
// Why: terminal copy/paste runs in the exact dashboard popout window, but its
// clipboard authority must not extend to image, file, or remote operations.
if (!isTrustedClipboardRenderer(event.sender) && !isDashboardPopoutRenderer(event.sender)) {
throw new Error('Unauthorized clipboard IPC sender')
}
}
function isTrustedClipboardRenderer(sender: WebContents): boolean {
if (sender.isDestroyed() || sender.getType() !== 'window') {
return false
@@ -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 {
@@ -12,17 +20,37 @@ const terminalHarness = vi.hoisted(() => ({
dispose: ReturnType<typeof vi.fn>
resize: ReturnType<typeof vi.fn>
reset: ReturnType<typeof vi.fn>
paste: ReturnType<typeof vi.fn>
input: ReturnType<typeof vi.fn>
modes: { bracketedPasteMode: boolean }
selectionText: string
customKeyHandler: ((event: KeyboardEvent) => boolean) | null
}[],
userInputListener: null as (() => void) | null,
userInputDispose: vi.fn()
}))
const platformState = vi.hoisted(() => ({ value: 'linux' }))
const imeHarness = vi.hoisted(() => ({
forwarders: [] as {
claimKeyEvent: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
sendInput: (data: string) => void
}[],
trackers: [] as { dispose: ReturnType<typeof vi.fn> }[],
claimResult: false,
inputSourceTrackerRequests: 0
}))
vi.mock('@xterm/xterm', () => ({
Terminal: class {
rows = 24
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 +61,20 @@ vi.mock('@xterm/xterm', () => ({
dispose = vi.fn()
resize = vi.fn()
reset = vi.fn()
modes = { bracketedPasteMode: false }
paste = vi.fn((data: string) => {
terminalHarness.userInputListener?.()
this.onDataListener?.(data)
})
input = vi.fn((data: string) => {
terminalHarness.userInputListener?.()
this.onDataListener?.(data)
})
element = document.createElement('div')
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,27 +97,67 @@ 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('@/lib/shortcut-platform', () => ({
getShortcutPlatform: () => platformState.value
}))
vi.mock('@/components/terminal-pane/terminal-ime-native-text-forwarder', () => ({
installTerminalImeNativeTextForwarder: (args: { sendInput: (data: string) => void }) => {
const forwarder = {
claimKeyEvent: vi.fn(() => imeHarness.claimResult),
dispose: vi.fn(),
sendInput: args.sendInput
}
imeHarness.forwarders.push(forwarder)
return forwarder
}
}))
vi.mock('@/components/terminal-pane/terminal-ime-composition-tracker', () => ({
installTerminalImeCompositionTracker: () => {
const tracker = { isActive: () => false, dispose: vi.fn() }
imeHarness.trackers.push(tracker)
return tracker
}
}))
vi.mock('@/components/terminal-pane/terminal-ime-input-source', () => ({
getMacNativeTextInputSourceTracker: () => {
imeHarness.inputSourceTrackerRequests++
return { getFeatures: () => ({}) }
}
}))
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'
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()
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
platformState.value = 'linux'
imeHarness.forwarders.length = 0
imeHarness.trackers.length = 0
imeHarness.claimResult = false
imeHarness.inputSourceTrackerRequests = 0
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 +169,14 @@ describe('AgentTerminalPreview', () => {
emitData = listener
return vi.fn()
}
},
ui: {
readClipboardText,
writeClipboardText,
onAppMenuPaste: (listener: () => void) => {
emitAppMenuPaste = listener
return vi.fn()
}
}
}
})
@@ -120,6 +210,265 @@ describe('AgentTerminalPreview', () => {
expect(ack).toHaveBeenCalledWith('pty-1', 4)
})
it('installs the macOS IME native-text forwarder and lets its claims bypass chord 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())
expect(imeHarness.forwarders).toHaveLength(1)
expect(imeHarness.trackers).toHaveLength(1)
expect(imeHarness.inputSourceTrackerRequests).toBe(1)
imeHarness.forwarders[0]!.sendInput('。')
expect(terminal.input).toHaveBeenCalledOnce()
expect(input).toHaveBeenCalledOnce()
expect(input).toHaveBeenCalledWith('pty-1', '。')
// A claimed native-text key bypasses xterm AND the clipboard chords.
imeHarness.claimResult = true
terminal.selectionText = 'selected text'
const handled = terminal.customKeyHandler!(
new KeyboardEvent('keydown', { key: 'C', code: 'KeyC', metaKey: true, shiftKey: true })
)
expect(handled).toBe(false)
expect(writeClipboardText).not.toHaveBeenCalled()
// 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 })
)
expect(copied).toBe(false)
expect(writeClipboardText).toHaveBeenCalledWith('selected text')
expect(imeHarness.inputSourceTrackerRequests).toBe(1)
})
it('does not install the IME native-text forwarder off macOS', async () => {
render(<AgentTerminalPreview ptyId="pty-1" />)
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
await waitFor(() => expect(terminalHarness.instances[0]!.customKeyHandler).not.toBeNull())
expect(imeHarness.forwarders).toHaveLength(0)
expect(imeHarness.trackers).toHaveLength(0)
})
it('disposes the IME bridge on unmount', async () => {
platformState.value = 'darwin'
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
await waitFor(() => expect(imeHarness.forwarders).toHaveLength(1))
view.unmount()
expect(imeHarness.forwarders[0]!.dispose).toHaveBeenCalledTimes(1)
expect(imeHarness.trackers[0]!.dispose).toHaveBeenCalledTimes(1)
})
it('disposes the IME bridge once when the PTY disappears', async () => {
platformState.value = 'darwin'
connect.mockResolvedValueOnce({
snapshot: { data: '', cols: 80, rows: 24, seq: 1 },
replay: []
})
connect.mockResolvedValueOnce({ snapshot: null, replay: [] })
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
await waitFor(() => expect(imeHarness.forwarders).toHaveLength(1))
act(() => emitData?.({ type: 'resync', ptyId: 'pty-1' }))
await waitFor(() => expect(imeHarness.forwarders[0]!.dispose).toHaveBeenCalledOnce())
expect(imeHarness.trackers[0]!.dispose).toHaveBeenCalledOnce()
view.unmount()
expect(imeHarness.forwarders[0]!.dispose).toHaveBeenCalledOnce()
expect(imeHarness.trackers[0]!.dispose).toHaveBeenCalledOnce()
})
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 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('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]!
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(false)
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'))
expect(input).toHaveBeenCalledWith('pty-1', '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 () => {
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 })
)
expect(plain).toBe(true)
expect(readClipboardText).not.toHaveBeenCalled()
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 encoder = new TextEncoder()
const multibytePrefix = '😀'.repeat(TERMINAL_PASTE_DIRECT_MAX_BYTES / 4 + 1)
const largePaste = `${multibytePrefix}\r\nnext\n`
const expectedPaste = `${multibytePrefix}\rnext\r`
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(
encoder.encode(expectedPaste).byteLength / 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(expectedPaste)
expect(
payloads.every(
(payload) => encoder.encode(payload).byteLength <= 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 () => {
let resolveRefresh!: (value: {
snapshot: { data: string; cols: number; rows: number; seq: number }
@@ -2,11 +2,28 @@ 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 {
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 {
installTerminalImeCompositionTracker,
type TerminalImeCompositionTracker
} from '@/components/terminal-pane/terminal-ime-composition-tracker'
import {
installTerminalImeNativeTextForwarder,
type TerminalImeNativeTextForwarder
} from '@/components/terminal-pane/terminal-ime-native-text-forwarder'
import { getMacNativeTextInputSourceTracker } from '@/components/terminal-pane/terminal-ime-input-source'
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'
@@ -57,6 +74,8 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
let terminal: Terminal | null = null
let offData: (() => void) | null = null
let userInputDisposable: { dispose: () => void } | null = null
let imeCompositionTracker: TerminalImeCompositionTracker | null = null
let imeNativeTextForwarder: TerminalImeNativeTextForwarder | null = null
let refreshInFlight = false
let refreshAgain = false
let retryTimer: ReturnType<typeof setTimeout> | null = null
@@ -115,6 +134,137 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
})
}
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
}
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 disposeImeNativeTextBridge = (): void => {
imeNativeTextForwarder?.dispose()
imeNativeTextForwarder = null
imeCompositionTracker?.dispose()
imeCompositionTracker = null
}
// Why: xterm's kitty encoder can encode+cancel a printable keydown before
// Chromium commits IME/native text, silently dropping the glyph (mirrors
// TerminalPane's forwarder; macOS-only like the pane's install).
const installImeNativeTextBridge = (): void => {
if (!terminal || getShortcutPlatform() !== 'darwin') {
return
}
// Why: prewarm the async input-source lookup before the first native-text key needs classification.
const inputSourceTracker = getMacNativeTextInputSourceTracker()
imeCompositionTracker = installTerminalImeCompositionTracker(terminal.element)
imeNativeTextForwarder = installTerminalImeNativeTextForwarder({
terminalElement: terminal.element,
isComposing: () => imeCompositionTracker?.isActive() ?? false,
sendInput: (data) => terminal?.input(data),
getInputSourceFeatures: () => inputSourceTracker.getFeatures()
})
}
const installClipboardShortcuts = (): void => {
if (!terminal) {
return
}
const platform = getShortcutPlatform()
const consumedClipboardKeys = new Set<string>()
const consumeEvent = (event: KeyboardEvent): false => {
event.preventDefault()
event.stopPropagation()
return false
}
terminal.attachCustomKeyEventHandler((event) => {
if (imeNativeTextForwarder?.claimKeyEvent(event)) {
// Why: bypass xterm's kitty encoder for native-text keydowns so the committed glyph survives via the input event.
return false
}
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 (firstKeydown && selection) {
void window.api.ui.writeClipboardText(selection).catch(() => undefined)
}
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 =
(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)
) {
const keyIdentity = event.code || event.key
if (!consumedClipboardKeys.has(keyIdentity)) {
consumedClipboardKeys.add(keyIdentity)
void pasteClipboardText(document.activeElement, 'keyboard')
}
return consumeEvent(event)
}
return true
})
}
const installInputRouting = (): void => {
if (!terminal) {
return
@@ -158,6 +308,8 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
return
}
installInputRouting()
installImeNativeTextBridge()
installClipboardShortcuts()
} else if (replaceExisting) {
// Why: keep the old frame visible during capture, then atomically replace it once the authoritative snapshot arrives.
terminal.resize(
@@ -222,6 +374,7 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
offData = null
userInputDisposable?.dispose()
userInputDisposable = null
disposeImeNativeTextBridge()
terminal?.dispose()
terminal = null
void window.api.terminalPreview.unsubscribe(ptyId)
@@ -235,6 +388,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(active, 'app-menu')
}
})
offData = window.api.terminalPreview.onData((payload) => {
if (payload.ptyId !== ptyId) {
return
@@ -253,8 +416,10 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
if (retryTimer) {
clearTimeout(retryTimer)
}
offAppMenuPaste()
offData?.()
userInputDisposable?.dispose()
disposeImeNativeTextBridge()
void window.api.terminalPreview.unsubscribe(ptyId)
terminal?.dispose()
}
@@ -1,14 +1,12 @@
import {
BRACKETED_PASTE_END,
BRACKETED_PASTE_START,
normalizeTerminalPasteLineEndings
} from './terminal-bracketed-paste'
import { BRACKETED_PASTE_END, BRACKETED_PASTE_START } from './terminal-bracketed-paste'
import { TERMINAL_PASTE_CHUNK_MAX_BYTES } from './terminal-paste-limits'
import type { TerminalPastePlan } from './terminal-paste-coordinator'
const TERMINAL_PASTE_ESCAPE_CODE_POINT = 0x1b
const TERMINAL_PASTE_INERT_ESCAPE_CODE_POINT = 0x241b
const TERMINAL_PASTE_INERT_ESCAPE = '\u241b'
const LINE_FEED_CODE_POINT = 0x0a
const CARRIAGE_RETURN_CODE_POINT = 0x0d
export function chunkTerminalPastePlan(plan: TerminalPastePlan): string[] {
return [...iterateTerminalPastePlanChunks(plan)]
@@ -16,16 +14,15 @@ export function chunkTerminalPastePlan(plan: TerminalPastePlan): string[] {
export function* iterateTerminalPastePlanChunks(plan: TerminalPastePlan): Generator<string> {
const maxChunkBytes = Math.max(4, plan.maxChunkBytes ?? TERMINAL_PASTE_CHUNK_MAX_BYTES)
// Why: normalize before chunking — a per-chunk pass could split a CRLF pair
// across a boundary and leak the LF half to ConPTY as a submit.
const text =
plan.newlinePolicy === 'terminal-cr'
? normalizeTerminalPasteLineEndings(plan.payload.plainText)
: plan.payload.plainText
if (plan.bracketed) {
yield BRACKETED_PASTE_START
}
yield* iterateTextByUtf8Bytes(text, maxChunkBytes, plan.bracketed)
yield* iterateTextByUtf8Bytes(
plan.payload.plainText,
maxChunkBytes,
plan.bracketed,
plan.newlinePolicy === 'terminal-cr'
)
if (plan.bracketed) {
yield BRACKETED_PASTE_END
}
@@ -34,19 +31,35 @@ export function* iterateTerminalPastePlanChunks(plan: TerminalPastePlan): Genera
function* iterateTextByUtf8Bytes(
text: string,
maxBytes: number,
sanitizeEscapes: boolean
sanitizeEscapes: boolean,
normalizeLineEndings: boolean
): Generator<string> {
let chunk = ''
let chunkBytes = 0
for (let index = 0; index < text.length; index += 1) {
const codePoint = text.codePointAt(index) ?? 0
const codeUnitLength = codePoint > 0xffff ? 2 : 1
// Why: iterator normalization avoids a full-size copy and keeps CRLF atomic across chunks.
if (
normalizeLineEndings &&
codePoint === LINE_FEED_CODE_POINT &&
index > 0 &&
text.charCodeAt(index - 1) === CARRIAGE_RETURN_CODE_POINT
) {
continue
}
const normalizedCodePoint =
normalizeLineEndings && codePoint === LINE_FEED_CODE_POINT
? CARRIAGE_RETURN_CODE_POINT
: codePoint
const sanitizedEscape = sanitizeEscapes && codePoint === TERMINAL_PASTE_ESCAPE_CODE_POINT
const next = sanitizedEscape
? TERMINAL_PASTE_INERT_ESCAPE
: text.slice(index, index + codeUnitLength)
: normalizedCodePoint === codePoint
? text.slice(index, index + codeUnitLength)
: '\r'
const nextBytes = utf8BytesForCodePoint(
sanitizedEscape ? TERMINAL_PASTE_INERT_ESCAPE_CODE_POINT : codePoint
sanitizedEscape ? TERMINAL_PASTE_INERT_ESCAPE_CODE_POINT : normalizedCodePoint
)
if (chunk && chunkBytes + nextBytes > maxBytes) {
yield chunk
@@ -1,7 +1,11 @@
import { describe, expect, it, vi } from 'vitest'
import { PASTE_PAYLOAD_CORPUS } from '../../lib/paste-payload-corpus'
import { BRACKETED_PASTE_END, BRACKETED_PASTE_START } from './terminal-bracketed-paste'
import {
BRACKETED_PASTE_END,
BRACKETED_PASTE_START,
normalizeTerminalPasteLineEndings
} from './terminal-bracketed-paste'
import { createRedactedPasteExecutionDiagnostic } from './terminal-paste-diagnostics'
import { formatTerminalPasteExecutionError } from './terminal-paste-errors'
import {
@@ -181,12 +185,14 @@ describe('terminal paste coordinator', () => {
expect(plan.mode).toBe('chunked')
expect(plan.runtimeKey).toBe('ssh:prod')
expect(pasteText).not.toHaveBeenCalled()
expect(writePty.mock.calls.map((call) => call[0]).join('')).toBe(text)
expect(writePty.mock.calls.map((call) => call[0]).join('')).toBe(
normalizeTerminalPasteLineEndings(text)
)
expect(writePty.mock.calls.length).toBeGreaterThan(1)
expect(yieldToEventLoop).toHaveBeenCalledTimes(writePty.mock.calls.length)
})
it('bracket-wraps large terminal-mode paste once and preserves newlines', async () => {
it('bracket-wraps large terminal-mode paste once with xterm newline semantics', async () => {
const text = 'alpha\r\nbeta\nbefore\x1b[201~after'
const plan = planTerminalPaste({
text,
@@ -200,7 +206,7 @@ describe('terminal paste coordinator', () => {
expect(chunks[0]).toBe(BRACKETED_PASTE_START)
expect(chunks.at(-1)).toBe(BRACKETED_PASTE_END)
expect(chunks.slice(1, -1).join('')).toBe('alpha\r\nbeta\nbefore␛[201~after')
expect(chunks.slice(1, -1).join('')).toBe('alpha\rbeta\rbefore␛[201~after')
expect(chunks.slice(1, -1).join('')).not.toContain('\x1b[201~')
})
@@ -264,7 +270,7 @@ describe('terminal paste coordinator', () => {
expect(chunkTerminalPastePlan(plan)).toEqual([...iterateTerminalPastePlanChunks(plan)])
})
it('preserves shared corpus payloads through non-bracketed chunk planning', () => {
it('applies terminal newline semantics to shared corpus payloads while chunking', () => {
for (const { hasRichText = false, name, text } of PASTE_PAYLOAD_CORPUS) {
const plan = planTerminalPaste({
hasRichText,
@@ -278,7 +284,7 @@ describe('terminal paste coordinator', () => {
expect(plan.mode, name).toBe('chunked')
expect(plan.payload.hasRichText, name).toBe(hasRichText)
expect(chunks.join(''), name).toBe(text)
expect(chunks.join(''), name).toBe(normalizeTerminalPasteLineEndings(text))
expect(plan.redactedDiagnostic, name).toContain('content=redacted')
expect(plan.redactedDiagnostic, name).toContain(`rich=${hasRichText}`)
expect(plan.redactedDiagnostic, name).not.toContain(text)
@@ -289,7 +295,7 @@ describe('terminal paste coordinator', () => {
}
})
it('bracket-wraps shared non-control corpus payloads once without rewriting content', () => {
it('bracket-wraps shared non-control corpus payloads with terminal newline semantics', () => {
for (const { expected, hasRichText = false, name, text } of PASTE_PAYLOAD_CORPUS) {
if (expected.hasControlSequences) {
continue
@@ -309,7 +315,7 @@ describe('terminal paste coordinator', () => {
expect(plan.bracketed, name).toBe(true)
expect(chunks[0], name).toBe(BRACKETED_PASTE_START)
expect(chunks.at(-1), name).toBe(BRACKETED_PASTE_END)
expect(chunks.slice(1, -1).join(''), name).toBe(text)
expect(chunks.slice(1, -1).join(''), name).toBe(normalizeTerminalPasteLineEndings(text))
expect(
chunks.filter((chunk) => chunk === BRACKETED_PASTE_START),
name
@@ -322,7 +328,7 @@ describe('terminal paste coordinator', () => {
}
})
it('preserves newline policy and literal text across terminal runtime identities', async () => {
it('uses xterm newline semantics across terminal runtime identities', async () => {
const text = getPastePayloadCorpusText('mixed newline text')
for (const { name, runtime } of RUNTIME_MATRIX) {
@@ -344,10 +350,12 @@ describe('terminal paste coordinator', () => {
})
expect(result.status, name).toBe('pasted')
expect(plan.newlinePolicy, name).toBe('preserve')
expect(plan.newlinePolicy, name).toBe('terminal-cr')
expect(plan.runtimeKey, name).toBe(runtime.runtimeKey)
expect(plan.redactedDiagnostic, name).toContain(`runtime=${runtime.runtimeKey}`)
expect(writePty.mock.calls.map((call) => call[0]).join(''), name).toBe(text)
expect(writePty.mock.calls.map((call) => call[0]).join(''), name).toBe(
normalizeTerminalPasteLineEndings(text)
)
}
})
@@ -173,7 +173,7 @@ function buildTerminalPastePlan({
target,
payload,
mode,
newlinePolicy: effectiveForceBracketedPaste ? 'terminal-cr' : 'preserve',
newlinePolicy: mode === 'chunked' || mode === 'bracketed-terminal' ? 'terminal-cr' : 'preserve',
runtimeKey: target.runtime.runtimeKey,
...(shouldChunk ? { maxChunkBytes } : {}),
bracketed: mode === 'bracketed-terminal' || (mode === 'chunked' && shouldBracketChunk),
+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 })