diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index f92271b1b30..a4ce809e1a9 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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 }) diff --git a/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts b/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts index dcedee5eace..329901c2ac8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.ts b/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.ts index 68562169019..5027b0b861c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.ts +++ b/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.ts @@ -8,6 +8,7 @@ type BracketedPasteTerminal = { type PasteTerminal = BracketedPasteTerminal & { options: Pick + 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 { diff --git a/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.test.ts b/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.test.ts index 9f2b6c3cb3c..5cf90b35fee 100644 --- a/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.test.ts @@ -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) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.ts b/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.ts index bc2bac524fa..b4c3ffdfb7c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.ts +++ b/src/renderer/src/components/terminal-pane/terminal-clipboard-paste.ts @@ -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 { 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) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index 9e5938f9eb8..c4ce956ea71 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -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(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() } },