Fix Windows multiline terminal paste

This commit is contained in:
Jinwoo Hong
2026-06-12 15:40:13 -07:00
parent 5f0a4fc5f3
commit fa2d482d72
6 changed files with 113 additions and 29 deletions
@@ -408,6 +408,9 @@ export default function TerminalPane({
// context menu. The settings default keeps the Windows shortcut feeling native
// without changing the other platforms' interaction model.
const rightClickToPaste = isWindowsUserAgent() && (settings?.terminalRightClickToPaste ?? true)
// Why: Windows ConPTY does not forward DECSET 2004 from foreground TUIs, so
// xterm may not know multi-line text needs bracketed-paste protection.
const forceBracketedMultilineTextPaste = isWindowsUserAgent()
const [startup] = useState(() => useAppStore.getState().pendingStartupByTabId[tabId])
const shouldMeasureHiddenStartup = startup !== undefined && !isVisible
const consumeTabStartupCommand = useAppStore((store) => store.consumeTabStartupCommand)
@@ -1283,9 +1286,10 @@ export default function TerminalPane({
readClipboardText: window.api.ui.readClipboardText,
saveClipboardImageAsTempFile: window.api.ui.saveClipboardImageAsTempFile,
connectionId,
forceBracketedMultilineTextPaste,
pasteText: (text, options) => {
pasteTerminalText(pane.terminal, text, options)
if (options?.forceBracketedPaste) {
if (options?.recoverImagePasteWebglAtlas) {
scheduleImagePasteWebglAtlasRecovery()
}
},
@@ -1391,7 +1395,7 @@ export default function TerminalPane({
container.removeEventListener('keydown', onKeyPaste, { capture: true })
container.removeEventListener('paste', onPaste, { capture: true })
}
}, [isActive, worktreeId, keybindings])
}, [isActive, worktreeId, keybindings, forceBracketedMultilineTextPaste])
// Why: a click inside the terminal container is a deliberate interaction
// with the pane — dismiss the attention indicator for this tab and worktree
@@ -1720,6 +1724,7 @@ export default function TerminalPane({
onSetTitle: handleStartRename,
onPasteError: setTerminalError,
onAgentSessionForkReady: setAgentSessionFork,
forceBracketedMultilineTextPaste,
rightClickToPaste
})
@@ -13,6 +13,7 @@ function createTerminal(bracketedPasteMode = true) {
options: {
ignoreBracketedPasteMode: false as boolean | undefined
},
input: vi.fn(),
paste: vi.fn()
}
return terminal
@@ -51,7 +52,7 @@ describe('terminal bracketed paste policy', () => {
it('forces bracketed paste behavior when requested after Ctrl+C', () => {
const terminal = createTerminal(true)
const observedIgnoreValues: (boolean | undefined)[] = []
terminal.paste.mockImplementation(() => {
terminal.input.mockImplementation(() => {
observedIgnoreValues.push(terminal.options.ignoreBracketedPasteMode)
})
@@ -60,17 +61,18 @@ describe('terminal bracketed paste policy', () => {
forceBracketedPaste: true
})
expect(terminal.paste).toHaveBeenCalledWith(
expect(terminal.input).toHaveBeenCalledWith(
'\x1b[200~/tmp/orca-paste-1760000000000-id.png\x1b[201~'
)
expect(observedIgnoreValues).toEqual([true])
expect(terminal.paste).not.toHaveBeenCalled()
expect(observedIgnoreValues).toEqual([false])
expect(terminal.options.ignoreBracketedPasteMode).toBe(false)
})
it('forces bracketed paste behavior even when terminal mode is off', () => {
const terminal = createTerminal(false)
const observedIgnoreValues: (boolean | undefined)[] = []
terminal.paste.mockImplementation(() => {
terminal.input.mockImplementation(() => {
observedIgnoreValues.push(terminal.options.ignoreBracketedPasteMode)
})
@@ -78,10 +80,11 @@ describe('terminal bracketed paste policy', () => {
forceBracketedPaste: true
})
expect(terminal.paste).toHaveBeenCalledWith(
expect(terminal.input).toHaveBeenCalledWith(
'\x1b[200~/tmp/orca-paste-1760000000000-id.png\x1b[201~'
)
expect(observedIgnoreValues).toEqual([true])
expect(terminal.paste).not.toHaveBeenCalled()
expect(observedIgnoreValues).toEqual([false])
expect(terminal.options.ignoreBracketedPasteMode).toBe(false)
})
@@ -92,7 +95,8 @@ describe('terminal bracketed paste policy', () => {
forceBracketedPaste: true
})
expect(terminal.paste).toHaveBeenCalledWith('\x1b[200~/tmp/before\u241b[201~after.png\x1b[201~')
expect(terminal.input).toHaveBeenCalledWith('\x1b[200~/tmp/before\u241b[201~after.png\x1b[201~')
expect(terminal.paste).not.toHaveBeenCalled()
})
it('does not change paste behavior when Ctrl+C happened outside bracketed paste mode', () => {
@@ -8,6 +8,7 @@ type BracketedPasteTerminal = {
type PasteTerminal = BracketedPasteTerminal & {
options: Pick<Terminal['options'], 'ignoreBracketedPasteMode'>
input: (data: string) => void
paste: (text: string) => void
}
@@ -39,15 +40,11 @@ function sanitizeBracketedPasteText(text: string): string {
}
function forceBracketedPaste(terminal: PasteTerminal, text: string): void {
const previousIgnoreBracketedPasteMode = terminal.options.ignoreBracketedPasteMode
terminal.options.ignoreBracketedPasteMode = true
try {
terminal.paste(
`${BRACKETED_PASTE_START}${sanitizeBracketedPasteText(text)}${BRACKETED_PASTE_END}`
)
} finally {
terminal.options.ignoreBracketedPasteMode = previousIgnoreBracketedPasteMode
}
// Why: forced callers already built the exact paste protocol bytes. Send
// them as PTY input so xterm's DOM/native paste machinery cannot defer them.
terminal.input(
`${BRACKETED_PASTE_START}${sanitizeBracketedPasteText(text)}${BRACKETED_PASTE_END}`
)
}
export function markTerminalBracketedPasteInterrupted(terminal: BracketedPasteTerminal): void {
@@ -22,7 +22,7 @@ describe('terminal clipboard paste', () => {
expect(pasteText).toHaveBeenCalledWith(
'/var/folders/3l/b7w02vh17tg5r5s3nhhdf3kh0000gn/T/orca-paste-1760000000000-id.png',
{ forceBracketedPaste: true }
{ forceBracketedPaste: true, recoverImagePasteWebglAtlas: true }
)
})
@@ -31,6 +31,9 @@ describe('terminal clipboard paste', () => {
const terminal = {
modes: { bracketedPasteMode: true },
options: { ignoreBracketedPasteMode: false },
input: vi.fn(() => {
observedIgnoreBracketedPasteMode.push(terminal.options.ignoreBracketedPasteMode)
}),
paste: vi.fn(() => {
observedIgnoreBracketedPasteMode.push(terminal.options.ignoreBracketedPasteMode)
})
@@ -45,10 +48,11 @@ describe('terminal clipboard paste', () => {
pasteText: (text, options) => pasteTerminalText(terminal, text, options)
})
expect(terminal.paste).toHaveBeenCalledWith(
expect(terminal.input).toHaveBeenCalledWith(
'\x1b[200~/tmp/orca-paste-1760000000000-id.png\x1b[201~'
)
expect(observedIgnoreBracketedPasteMode).toEqual([true])
expect(terminal.paste).not.toHaveBeenCalled()
expect(observedIgnoreBracketedPasteMode).toEqual([false])
expect(terminal.options.ignoreBracketedPasteMode).toBe(false)
})
@@ -57,6 +61,9 @@ describe('terminal clipboard paste', () => {
const terminal = {
modes: { bracketedPasteMode: false },
options: { ignoreBracketedPasteMode: false },
input: vi.fn(() => {
observedIgnoreBracketedPasteMode.push(terminal.options.ignoreBracketedPasteMode)
}),
paste: vi.fn(() => {
observedIgnoreBracketedPasteMode.push(terminal.options.ignoreBracketedPasteMode)
})
@@ -70,10 +77,11 @@ describe('terminal clipboard paste', () => {
pasteText: (text, options) => pasteTerminalText(terminal, text, options)
})
expect(terminal.paste).toHaveBeenCalledWith(
expect(terminal.input).toHaveBeenCalledWith(
'\x1b[200~/tmp/orca-paste-1760000000000-id.png\x1b[201~'
)
expect(observedIgnoreBracketedPasteMode).toEqual([true])
expect(terminal.paste).not.toHaveBeenCalled()
expect(observedIgnoreBracketedPasteMode).toEqual([false])
expect(terminal.options.ignoreBracketedPasteMode).toBe(false)
})
@@ -92,7 +100,8 @@ describe('terminal clipboard paste', () => {
expect(saveClipboardImageAsTempFile).toHaveBeenCalledWith({ connectionId: 'ssh-1' })
expect(pasteText).toHaveBeenCalledWith('/var/tmp/orca-paste-1760000000000-id.png', {
forceBracketedPaste: true
forceBracketedPaste: true,
recoverImagePasteWebglAtlas: true
})
})
@@ -108,7 +117,8 @@ describe('terminal clipboard paste', () => {
})
expect(pasteText).toHaveBeenCalledWith('/tmp/orca-paste-1760000000000-id.png', {
forceBracketedPaste: true
forceBracketedPaste: true,
recoverImagePasteWebglAtlas: true
})
})
@@ -126,7 +136,8 @@ describe('terminal clipboard paste', () => {
expect(saveClipboardImageAsTempFile).toHaveBeenCalledWith({ connectionId: undefined })
expect(pasteText).toHaveBeenCalledWith('/tmp/orca-paste-1760000000000-id.png', {
forceBracketedPaste: true
forceBracketedPaste: true,
recoverImagePasteWebglAtlas: true
})
})
@@ -144,11 +155,65 @@ describe('terminal clipboard paste', () => {
expect(saveClipboardImageAsTempFile).not.toHaveBeenCalled()
})
it('forces Windows multi-line text paste onto the bracketed-paste path', async () => {
const saveClipboardImageAsTempFile = vi.fn()
const pasteText = vi.fn()
await pasteTerminalClipboard({
readClipboardText: vi.fn().mockResolvedValue('line one\nline two'),
saveClipboardImageAsTempFile,
pasteText,
forceBracketedMultilineTextPaste: true
})
expect(pasteText).toHaveBeenCalledWith('line one\nline two', {
forceBracketedPaste: true
})
expect(saveClipboardImageAsTempFile).not.toHaveBeenCalled()
})
it('sends Windows multi-line text as direct bracketed terminal input when xterm mode is off', async () => {
const terminal = {
modes: { bracketedPasteMode: false },
options: { ignoreBracketedPasteMode: false },
input: vi.fn(),
paste: vi.fn()
}
await pasteTerminalClipboard({
readClipboardText: vi.fn().mockResolvedValue('line one\nline two'),
saveClipboardImageAsTempFile: vi.fn(),
pasteText: (text, options) => pasteTerminalText(terminal, text, options),
forceBracketedMultilineTextPaste: true
})
expect(terminal.input).toHaveBeenCalledWith('\x1b[200~line one\nline two\x1b[201~')
expect(terminal.paste).not.toHaveBeenCalled()
})
it('keeps single-line text on the ordinary paste path when Windows multi-line protection is on', async () => {
const saveClipboardImageAsTempFile = vi.fn()
const pasteText = vi.fn()
await pasteTerminalClipboard({
readClipboardText: vi.fn().mockResolvedValue('hello'),
saveClipboardImageAsTempFile,
pasteText,
forceBracketedMultilineTextPaste: true
})
expect(pasteText).toHaveBeenCalledWith('hello')
expect(saveClipboardImageAsTempFile).not.toHaveBeenCalled()
})
it('keeps normal single-line text paste on the stale Ctrl+C protection path', async () => {
const observedIgnoreBracketedPasteMode: boolean[] = []
const terminal = {
modes: { bracketedPasteMode: true },
options: { ignoreBracketedPasteMode: false },
input: vi.fn(() => {
observedIgnoreBracketedPasteMode.push(terminal.options.ignoreBracketedPasteMode)
}),
paste: vi.fn(() => {
observedIgnoreBracketedPasteMode.push(terminal.options.ignoreBracketedPasteMode)
})
@@ -1,5 +1,6 @@
type PasteTextOptions = {
forceBracketedPaste?: boolean
recoverImagePasteWebglAtlas?: boolean
}
type SaveClipboardImageAsTempFile = (args?: {
@@ -11,14 +12,18 @@ type PasteTerminalClipboardDeps = {
saveClipboardImageAsTempFile: SaveClipboardImageAsTempFile
pasteText: (text: string, options?: PasteTextOptions) => void
connectionId?: string | null
forceBracketedMultilineTextPaste?: boolean
onImagePasteError?: (error: unknown) => void
}
const MULTILINE_TEXT_RE = /[\r\n]/
export async function pasteTerminalClipboard({
readClipboardText,
saveClipboardImageAsTempFile,
pasteText,
connectionId,
forceBracketedMultilineTextPaste = false,
onImagePasteError
}: PasteTerminalClipboardDeps): Promise<void> {
let text = ''
@@ -29,7 +34,11 @@ export async function pasteTerminalClipboard({
// Still try the image path so Cmd/Ctrl+V works for screenshots.
}
if (text) {
pasteText(text)
if (forceBracketedMultilineTextPaste && MULTILINE_TEXT_RE.test(text)) {
pasteText(text, { forceBracketedPaste: true })
} else {
pasteText(text)
}
return
}
@@ -41,7 +50,8 @@ export async function pasteTerminalClipboard({
pasteText(filePath, {
// Why: a generated clipboard-image path is terminal image injection, not
// ordinary one-line text. Keep it off the Ctrl+C stale-text paste path.
forceBracketedPaste: true
forceBracketedPaste: true,
recoverImagePasteWebglAtlas: true
})
} catch (error) {
onImagePasteError?.(error)
@@ -53,6 +53,7 @@ type UseTerminalPaneContextMenuDeps = {
onSetTitle: (paneId: number) => void
onPasteError: (message: string) => void
onAgentSessionForkReady: (fork: PreparedAgentSessionFork) => void
forceBracketedMultilineTextPaste: boolean
rightClickToPaste: boolean
}
@@ -92,6 +93,7 @@ export function useTerminalPaneContextMenu({
onSetTitle,
onPasteError,
onAgentSessionForkReady,
forceBracketedMultilineTextPaste,
rightClickToPaste
}: UseTerminalPaneContextMenuDeps): TerminalMenuState {
const contextPaneIdRef = useRef<number | null>(null)
@@ -168,9 +170,10 @@ export function useTerminalPaneContextMenu({
readClipboardText: window.api.ui.readClipboardText,
saveClipboardImageAsTempFile: window.api.ui.saveClipboardImageAsTempFile,
connectionId,
forceBracketedMultilineTextPaste,
pasteText: (text, options) => {
pasteTerminalText(pane.terminal, text, options)
if (options?.forceBracketedPaste) {
if (options?.recoverImagePasteWebglAtlas) {
scheduleImagePasteWebglAtlasRecovery()
}
},