mirror of
https://github.com/stablyai/orca.git
synced 2026-10-15 16:02:49 +00:00
Fix Windows multiline terminal paste
This commit is contained in:
@@ -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()
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user