mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 16:02:35 +00:00
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:
co-authored by
OrcaWin
OrcaWin
parent
15362fde16
commit
6458ebcf20
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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