diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts index 26e8f21d3fe..caa1e1934ce 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts @@ -393,6 +393,36 @@ describe('handleTerminalFileDrop', () => { expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') }) + it('pastes a spaced image from a Windows-path project forced to WSL with POSIX escaping', async () => { + mocks.storeState.settings = { activeRuntimeEnvironmentId: null } + mocks.storeState.projects = [ + { id: 'repo1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } } + ] + mocks.storeState.repos = [ + { id: 'repo1', connectionId: null, path: 'C:\\Users\\alice\\repo', executionHostId: 'local' } + ] + mocks.storeState.worktreesByRepo = { + repo1: [{ id: 'wt-1', repoId: 'repo1', path: 'C:\\Users\\alice\\repo' }] + } + const sendInput = vi.fn(() => true) + const pane = { id: 1, leafId: 'leaf-1', terminal: { focus: vi.fn() } } + + await handleTerminalFileDrop({ + manager: { getActivePane: () => pane, getPanes: () => [pane] } as never, + paneTransports: new Map([[1, createTerminalTransport(sendInput)]]) as never, + worktreeId: 'wt-1', + tabId: 'tab-1', + cwd: undefined, + data: { paths: ['C:\\Users\\alice\\Desktop\\Screenshot 1.png'], target: 'terminal' } + }) + + // Why: the agent runs in Linux, so a Windows-style quote would reach it as a literal. + expect(sendInput).toHaveBeenCalledWith( + wrapTerminalBracketedPasteText('/mnt/c/Users/alice/Desktop/Screenshot\\ 1.png'), + 'driving' + ) + }) + it('uses acknowledged PTY writes for native local drops when available', async () => { mocks.storeState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } mocks.storeState.repos = [ @@ -619,6 +649,46 @@ describe('handleTerminalFileDrop', () => { expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') }) + it('pastes a spaced image dropped on a Windows SSH host with Windows quoting', async () => { + mocks.storeState.settings = { activeRuntimeEnvironmentId: null } + mocks.storeState.repos = [ + { + id: 'repo1', + connectionId: 'ssh-win', + path: 'C:\\Remote Repo', + executionHostId: 'ssh:ssh-win' + } + ] + mocks.storeState.worktreesByRepo = { + repo1: [{ id: 'wt-1', repoId: 'repo1', path: 'C:\\Remote Repo' }] + } + mocks.storeState.sshConnectionStates = new Map([ + ['ssh-win', { remotePlatform: 'win32', connectionGeneration: 4 }] + ]) + mocks.resolveDroppedPathsForAgent.mockResolvedValue({ + failed: [], + resolvedPaths: ['C:\\Remote Repo\\.orca\\drops\\Screenshot 1.png'], + skipped: [] + }) + const sendInput = vi.fn(() => true) + const pane = { id: 1, leafId: 'leaf-1', terminal: { focus: vi.fn() } } + + await handleTerminalFileDrop({ + manager: { getActivePane: () => pane, getPanes: () => [pane] } as never, + paneTransports: new Map([[1, createTerminalTransport(sendInput)]]) as never, + worktreeId: 'wt-1', + tabId: 'tab-1', + cwd: undefined, + data: { paths: ['/Users/me/Screenshot 1.png'], target: 'terminal' } + }) + + // Why: agents on Windows keep backslashes, so POSIX escaping would corrupt the path. + expect(sendInput).toHaveBeenCalledWith( + wrapTerminalBracketedPasteText('"C:\\Remote Repo\\.orca\\drops\\Screenshot 1.png"'), + 'driving' + ) + }) + it('surfaces stale SSH owner capture failures without rejecting the native drop', async () => { mocks.storeState.settings = { activeRuntimeEnvironmentId: null } mocks.storeState.repos = [ diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-image-path.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-image-path.test.ts index 190142f1bf5..248f69dedf7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-image-path.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-image-path.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { isImageDropPath } from './terminal-drop-image-path' +import { + canPasteImageDropPathRaw, + formatImageDropPasteText, + isImageDropPath +} from './terminal-drop-image-path' describe('isImageDropPath', () => { it('detects common image extensions case-insensitively', () => { @@ -36,3 +40,164 @@ describe('isImageDropPath', () => { expect(isImageDropPath('/home/jane.doe/screenshot')).toBe(false) }) }) + +describe('canPasteImageDropPathRaw', () => { + it('pastes plain names raw', () => { + expect(canPasteImageDropPathRaw('/tmp/orca-paste-1-abc.png', 'posix')).toBe(true) + expect(canPasteImageDropPathRaw('C:\\Temp\\orca-paste-1-abc.png', 'windows')).toBe(true) + }) + + it('treats an ASCII space as unsafe but leaves U+202F alone', () => { + expect( + canPasteImageDropPathRaw('/t/Screenshot 2026-09-28 at 4.03.11\u202fPM.png', 'posix') + ).toBe(false) + expect(canPasteImageDropPathRaw('C:\\My Pictures\\shot.png', 'windows')).toBe(false) + expect(canPasteImageDropPathRaw('/t/4.03.11\u202fPM.png', 'posix')).toBe(true) + }) +}) + +// Why: small replays of how agents recover a path from pasted text, so the +// escaped form is checked against what they actually accept. + +// Claude Code 2.1.285 `S` then `x`: strip one pair of outer quotes, then +// unescape `\X` to `X` with `\\` kept as one backslash. +function claudeCodeRecoverPath(pasted: string): string { + const unquoted = /^(['"]).*\1$/s.test(pasted) ? pasted.slice(1, -1) : pasted + return unquoted.replace(/\\(.)/gs, '$1') +} + +// Codex `normalize_pasted_path`: a drive/UNC path skips shlex once one outer +// quote pair is stripped; anything else must shlex-split into exactly one token. +function codexRecoverPath(pasted: string): string | null { + const unquoted = pasted.replace(/^(['"])(.*)\1$/s, '$2') + if (/^([a-z]:[\\/]|\\\\)/i.test(unquoted)) { + return unquoted + } + const tokens: string[] = [] + let current: string | null = null + let quote: "'" | '"' | null = null + for (let i = 0; i < pasted.length; i += 1) { + const char = pasted[i] + if (quote === "'") { + if (char === "'") { + quote = null + } else { + current += char + } + } else if (quote === '"') { + if (char === '"') { + quote = null + } else if (char === '\\' && /["\\$`]/.test(pasted[i + 1] ?? '')) { + current += pasted[++i] + } else { + current += char + } + } else if (char === ' ' || char === '\t' || char === '\n') { + if (current !== null) { + tokens.push(current) + current = null + } + } else { + current ??= '' + if (char === '\\') { + current += pasted[++i] ?? '' + } else if (char === "'" || char === '"') { + quote = char + } else { + current += char + } + } + } + if (quote !== null) { + return null + } + if (current !== null) { + tokens.push(current) + } + return tokens.length === 1 ? tokens[0] : null +} + +describe('formatImageDropPasteText', () => { + it('backslash-escapes unsafe POSIX characters and double-quotes on Windows', () => { + expect(formatImageDropPasteText('/t/shot.png', 'posix')).toBe('/t/shot.png') + expect(formatImageDropPasteText("/t/it's (1).png", 'posix')).toBe("/t/it\\'s\\ \\(1\\).png") + expect(formatImageDropPasteText('C:\\My Pictures\\shot.png', 'windows')).toBe( + '"C:\\My Pictures\\shot.png"' + ) + }) + + it('refuses paths a paste frame would alter', () => { + expect(formatImageDropPasteText('/t/a\nb.png', 'posix')).toBeNull() + expect(formatImageDropPasteText('/t/a\u001bb.png', 'posix')).toBeNull() + expect(formatImageDropPasteText('C:\\t\\a\u0007b.png', 'windows')).toBeNull() + }) +}) + +// pi-image-paste 1.0.0 `tokenizePathLikeText` + `tryExtendBareToken`, bare tokens +// only (the escaped POSIX form never starts with a quote): a token keeps `\X` +// pairs, stops at any JS whitespace (U+202F included) and is shell-unescaped; +// if no file exists it extends across up to 8 following words. +function piImagePasteRecoverPath(pasted: string, exists: (path: string) => boolean): string | null { + const unescape = (text: string): string => text.replace(/\\(.)/gs, '$1') + let index = 0 + let raw = '' + while (index < pasted.length && !/\s/.test(pasted[index])) { + if (pasted[index] === '\\' && index + 1 < pasted.length) { + raw += pasted.slice(index, index + 2) + index += 2 + } else { + raw += pasted[index++] + } + } + let value = unescape(raw) + for (let step = 0; step < 8 && !exists(value); step += 1) { + let wordStart = index + while ( + wordStart < pasted.length && + /\s/.test(pasted[wordStart]) && + pasted[wordStart] !== '\n' + ) { + wordStart += 1 + } + let wordEnd = wordStart + while (wordEnd < pasted.length && !/\s/.test(pasted[wordEnd])) { + wordEnd += 1 + } + if (wordStart === index || wordEnd === wordStart) { + break + } + value += pasted.slice(index, wordStart) + unescape(pasted.slice(wordStart, wordEnd)) + index = wordEnd + } + return exists(value) ? value : null +} + +describe('agent recovery of pasted image paths', () => { + const screenshot = '/t/Screenshot 2026-09-28 at 4.03.11\u202fPM.png' + + it.each([ + screenshot, + "/t/it's.png", + '/t/download (1).png', + '/t/a.png; touch /tmp/pwned #.png', + '/t/"q" $HOME `x` \\ [1] {2} *?!&|<>~.png' + ])('recovers %j in Claude Code, Codex and Pi', (path) => { + const pasted = formatImageDropPasteText(path, 'posix') ?? '' + expect(claudeCodeRecoverPath(pasted)).toBe(path) + expect(codexRecoverPath(pasted)).toBe(path) + expect(piImagePasteRecoverPath(pasted, (candidate) => candidate === path)).toBe(path) + }) + + it("shows why the quoted `'\\''` form was not used: Claude Code corrupts it", () => { + expect(claudeCodeRecoverPath("'/t/it'\\''s.png'")).toBe("/t/it'''s.png") + }) + + it('recovers a double-quoted spaced Windows path in Codex', () => { + const path = 'C:\\Users\\me\\My Pictures\\shot.png' + expect(codexRecoverPath(formatImageDropPasteText(path, 'windows') ?? '')).toBe(path) + }) + + it('shows why a raw spaced path is not enough for Codex', () => { + expect(codexRecoverPath(screenshot)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-image-path.ts b/src/renderer/src/components/terminal-pane/terminal-drop-image-path.ts index 549d043ea8d..6f4cc98f031 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-image-path.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-image-path.ts @@ -1,4 +1,5 @@ import { IMAGE_FILE_EXTENSIONS } from '../../../../shared/image-file-extensions' +import { shellEscapePath } from './pane-helpers' import type { TerminalTargetShell } from './terminal-drop-shell' // Why: dropped image files should be handed to terminal TUIs (Claude Code, @@ -6,8 +7,9 @@ import type { TerminalTargetShell } from './terminal-drop-shell' // *bracketed paste* of the file path — exactly how clipboard screenshot paste // already works in Orca (see terminal-clipboard-paste.ts + issue #2842). const IMAGE_DROP_EXTENSIONS = new Set(IMAGE_FILE_EXTENSIONS) -const POSIX_RAW_IMAGE_DROP_UNSAFE_RE = /["'`$;&|<>(){}[\]*?!#\\]/ -const WINDOWS_RAW_IMAGE_DROP_UNSAFE_RE = /["'`$;&|<>(){}[\]*?!#^%]/ +// Why: an ASCII space is unsafe because Codex shlex-splits a raw spaced path into several tokens. +const POSIX_RAW_IMAGE_DROP_UNSAFE_RE = /[ "'`$;&|<>(){}[\]*?!#\\]/ +const WINDOWS_RAW_IMAGE_DROP_UNSAFE_RE = /[ "'`$;&|<>(){}[\]*?!#^%]/ /** * Returns true when `path` looks like a local/remote image file based on its @@ -36,6 +38,31 @@ export function canPasteImageDropPathRaw(path: string, targetShell: TerminalTarg return !unsafeRe.test(path) } +/** + * Text to bracketed-paste for a dropped image, or null when a paste would alter + * the path (control bytes are rewritten by the paste frame). + */ +export function formatImageDropPasteText( + path: string, + targetShell: TerminalTargetShell +): string | null { + if (hasControlByte(path)) { + return null + } + if (canPasteImageDropPathRaw(path, targetShell)) { + return path + } + // Why: Claude Code strips only one outer quote pair, so a POSIX `'\''` splice + // leaks quotes into the path; backslash escapes round-trip in Claude, Codex, and shells. + return targetShell === 'windows' + ? shellEscapePath(path, 'windows') + : backslashEscapePosixPath(path) +} + +function backslashEscapePosixPath(path: string): string { + return path.replace(/[^a-zA-Z0-9_./@:\-\u0080-\uffff]/g, '\\$&') +} + function hasControlByte(path: string): boolean { for (let i = 0; i < path.length; i += 1) { const code = path.charCodeAt(i) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts index 20fe46c1fda..4369579740e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts @@ -62,7 +62,7 @@ describe('terminal drop path writer', () => { dropTarget: { paneId: pane.id, leafId: pane.leafId, ptyId: 'pty-1', transport } as never, manager: manager as never, paneTransports: new Map([[pane.id, transport]]) as never, - paths: ['/repo/My Screenshot.png'], + paths: ['/tmp/orca-paste-1-abc.png'], targetShell: 'posix' }) @@ -70,11 +70,82 @@ describe('terminal drop path writer', () => { // Why: image attachment detection in terminal TUIs keys off bracketed paste // of the literal path — no shell-escaping, no trailing space. expect(sendInputAccepted).toHaveBeenCalledWith( - wrapTerminalBracketedPasteText('/repo/My Screenshot.png'), + wrapTerminalBracketedPasteText('/tmp/orca-paste-1-abc.png'), 'driving' ) }) + it('pastes a spaced macOS screenshot name as a backslash-escaped path', async () => { + const sendInput = vi.fn(() => true) + const sendInputAccepted = vi.fn(async () => true) + const { manager, pane } = createManager() + const transport = createTransport(sendInput, 'pty-1', sendInputAccepted) + + await writeTerminalDropPathsToCapturedTarget({ + dropTarget: { paneId: pane.id, leafId: pane.leafId, ptyId: 'pty-1', transport } as never, + manager: manager as never, + paneTransports: new Map([[pane.id, transport]]) as never, + paths: ['/tmp/drag/Screenshot 2026-09-28 at 4.03.11\u202fPM.png'], + targetShell: 'posix' + }) + + // Why: pasted raw, Codex shlex-splits the ASCII spaces and keeps the path as text. + expect(sendInputAccepted).toHaveBeenCalledTimes(1) + expect(sendInputAccepted).toHaveBeenCalledWith( + wrapTerminalBracketedPasteText( + '/tmp/drag/Screenshot\\ 2026-09-28\\ at\\ 4.03.11\u202fPM.png' + ), + 'driving' + ) + }) + + it('pastes a spaced Windows image name as a double-quoted path', async () => { + const sendInput = vi.fn(() => true) + const sendInputAccepted = vi.fn(async () => true) + const { manager, pane } = createManager() + const transport = createTransport(sendInput, 'pty-1', sendInputAccepted) + + await writeTerminalDropPathsToCapturedTarget({ + dropTarget: { paneId: pane.id, leafId: pane.leafId, ptyId: 'pty-1', transport } as never, + manager: manager as never, + paneTransports: new Map([[pane.id, transport]]) as never, + paths: ['C:\\Users\\me\\My Pictures\\shot.png'], + targetShell: 'windows' + }) + + expect(sendInputAccepted).toHaveBeenCalledWith( + wrapTerminalBracketedPasteText('"C:\\Users\\me\\My Pictures\\shot.png"'), + 'driving' + ) + }) + + it('keeps separating spaces around escaped spaced image pastes', async () => { + const sendInput = vi.fn(() => true) + const sendInputAccepted = vi.fn(async () => true) + const { manager, pane } = createManager() + const transport = createTransport(sendInput, 'pty-1', sendInputAccepted) + + await writeTerminalDropPathsToCapturedTarget({ + dropTarget: { paneId: pane.id, leafId: pane.leafId, ptyId: 'pty-1', transport } as never, + manager: manager as never, + paneTransports: new Map([[pane.id, transport]]) as never, + paths: ['/repo/Screenshot 1.png', '/repo/Screenshot 2.png', '/repo/a.ts'], + targetShell: 'posix' + }) + + expect(sendInputAccepted).toHaveBeenNthCalledWith( + 1, + `${wrapTerminalBracketedPasteText('/repo/Screenshot\\ 1.png')} `, + 'driving' + ) + expect(sendInputAccepted).toHaveBeenNthCalledWith( + 2, + `${wrapTerminalBracketedPasteText('/repo/Screenshot\\ 2.png')} `, + 'driving' + ) + expect(sendInputAccepted).toHaveBeenNthCalledWith(3, '/repo/a.ts ', 'driving') + }) + it('keeps shell-escaped input for mixed image and non-image drops', async () => { const sendInput = vi.fn(() => true) const sendInputAccepted = vi.fn(async () => true) @@ -149,7 +220,7 @@ describe('terminal drop path writer', () => { ) }) - it('falls back to shell escaping for image paths with POSIX shell metacharacters', async () => { + it('pastes a shell-escaped image path with POSIX shell metacharacters', async () => { const sendInput = vi.fn(() => true) const sendInputAccepted = vi.fn(async () => true) const { manager, pane } = createManager() @@ -163,13 +234,94 @@ describe('terminal drop path writer', () => { targetShell: 'posix' }) + // Why: the text stays escaped so a shell still gets one argument; the + // paste frame is what lets agent TUIs attach the image. + expect(sendInputAccepted).toHaveBeenCalledTimes(1) expect(sendInputAccepted).toHaveBeenCalledWith( - "'/repo/a.png; touch /tmp/pwned #.png' ", + wrapTerminalBracketedPasteText('/repo/a.png\\;\\ touch\\ /tmp/pwned\\ \\#.png'), 'driving' ) }) - it('falls back to shell escaping for image paths with Windows shell metacharacters', async () => { + it('pastes an image filename with parentheses as a backslash-escaped path', async () => { + const sendInput = vi.fn(() => true) + const sendInputAccepted = vi.fn(async () => true) + const { manager, pane } = createManager() + const transport = createTransport(sendInput, 'pty-1', sendInputAccepted) + + await writeTerminalDropPathsToCapturedTarget({ + dropTarget: { paneId: pane.id, leafId: pane.leafId, ptyId: 'pty-1', transport } as never, + manager: manager as never, + paneTransports: new Map([[pane.id, transport]]) as never, + paths: ['/repo/.orca/drops/download (1).png'], + targetShell: 'posix' + }) + + // Why: browser download names like `download (1).png` were written as + // unframed keystrokes, so agent TUIs left the path as text. + expect(sendInputAccepted).toHaveBeenCalledTimes(1) + expect(sendInputAccepted).toHaveBeenCalledWith( + wrapTerminalBracketedPasteText('/repo/.orca/drops/download\\ \\(1\\).png'), + 'driving' + ) + }) + + it('keeps shell-escaped image pastes separate shell arguments from following images', async () => { + const sendInput = vi.fn(() => true) + const sendInputAccepted = vi.fn(async () => true) + const { manager, pane } = createManager() + const transport = createTransport(sendInput, 'pty-1', sendInputAccepted) + + await writeTerminalDropPathsToCapturedTarget({ + dropTarget: { paneId: pane.id, leafId: pane.leafId, ptyId: 'pty-1', transport } as never, + manager: manager as never, + paneTransports: new Map([[pane.id, transport]]) as never, + paths: ['/repo/download (1).png', "/repo/it's.png", '/repo/shot.png'], + targetShell: 'posix' + }) + + // Why: a shell ignores paste boundaries, so without the space + // `/repo/download\ \(1\).png/repo/it\'s.png` would be one argument. + expect(sendInputAccepted).toHaveBeenNthCalledWith( + 1, + `${wrapTerminalBracketedPasteText('/repo/download\\ \\(1\\).png')} `, + 'driving' + ) + expect(sendInputAccepted).toHaveBeenNthCalledWith( + 2, + `${wrapTerminalBracketedPasteText("/repo/it\\'s.png")} `, + 'driving' + ) + expect(sendInputAccepted).toHaveBeenNthCalledWith( + 3, + wrapTerminalBracketedPasteText('/repo/shot.png'), + 'driving' + ) + }) + + it('separates a shell-escaped image paste from a following non-image path', async () => { + const sendInput = vi.fn(() => true) + const sendInputAccepted = vi.fn(async () => true) + const { manager, pane } = createManager() + const transport = createTransport(sendInput, 'pty-1', sendInputAccepted) + + await writeTerminalDropPathsToCapturedTarget({ + dropTarget: { paneId: pane.id, leafId: pane.leafId, ptyId: 'pty-1', transport } as never, + manager: manager as never, + paneTransports: new Map([[pane.id, transport]]) as never, + paths: ['/repo/download (1).png', '/repo/a.ts'], + targetShell: 'posix' + }) + + expect(sendInputAccepted).toHaveBeenNthCalledWith( + 1, + `${wrapTerminalBracketedPasteText('/repo/download\\ \\(1\\).png')} `, + 'driving' + ) + expect(sendInputAccepted).toHaveBeenNthCalledWith(2, '/repo/a.ts ', 'driving') + }) + + it('pastes a quoted image path with Windows shell metacharacters', async () => { const sendInput = vi.fn(() => true) const sendInputAccepted = vi.fn(async () => true) const { manager, pane } = createManager() @@ -183,7 +335,10 @@ describe('terminal drop path writer', () => { targetShell: 'windows' }) - expect(sendInputAccepted).toHaveBeenCalledWith('"C:\\Users\\me\\Pictures\\a&b.png" ', 'driving') + expect(sendInputAccepted).toHaveBeenCalledWith( + wrapTerminalBracketedPasteText('"C:\\Users\\me\\Pictures\\a&b.png"'), + 'driving' + ) }) it('separates an image paste from a following image path that must be shell escaped', async () => { @@ -207,11 +362,29 @@ describe('terminal drop path writer', () => { ) expect(sendInputAccepted).toHaveBeenNthCalledWith( 2, - "'/repo/a.png; touch /tmp/pwned #.png' ", + wrapTerminalBracketedPasteText('/repo/a.png\\;\\ touch\\ /tmp/pwned\\ \\#.png'), 'driving' ) }) + it('types image paths with control bytes instead of pasting them', async () => { + const sendInput = vi.fn(() => true) + const sendInputAccepted = vi.fn(async () => true) + const { manager, pane } = createManager() + const transport = createTransport(sendInput, 'pty-1', sendInputAccepted) + + await writeTerminalDropPathsToCapturedTarget({ + dropTarget: { paneId: pane.id, leafId: pane.leafId, ptyId: 'pty-1', transport } as never, + manager: manager as never, + paneTransports: new Map([[pane.id, transport]]) as never, + paths: ['/repo/a\nb.png'], + targetShell: 'posix' + }) + + // Why: the paste frame rewrites LF to CR and ESC to a symbol, changing the path. + expect(sendInputAccepted).toHaveBeenCalledWith("'/repo/a\nb.png' ", 'driving') + }) + it('times out dropped path writes that never receive PTY acknowledgement', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts index 824a812ddb7..007a19d7f9a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts @@ -3,7 +3,11 @@ import { separateImagePasteFromFollowingText } from '../../../../shared/image-pa import { shellEscapePath } from './pane-helpers' import type { PtyTransport } from './pty-transport' import { wrapTerminalBracketedPasteText } from './terminal-bracketed-paste' -import { canPasteImageDropPathRaw, isImageDropPath } from './terminal-drop-image-path' +import { + canPasteImageDropPathRaw, + formatImageDropPasteText, + isImageDropPath +} from './terminal-drop-image-path' import { type CapturedTerminalDropTarget, getCurrentTerminalDropTransport @@ -46,31 +50,41 @@ export async function writeTerminalDropPathsToCapturedTarget({ return { sentAnyPath, targetCurrent: false, pathsWritten, failureReason: 'target-stale' } } // Why: image drops are attachment payloads for terminal TUIs, which detect - // them from a bracketed paste of the raw (un-escaped) path — mirroring the - // clipboard screenshot flow (terminal-clipboard-paste.ts, issue #2842). - // Shell-escaping would corrupt the file-existence check those tools run on - // the pasted path, so safe image paths bypass it. Unsafe image paths and - // non-image drops keep the original shell-escaped, space-separated - // behaviour for use in shell commands. + // them from a bracketed paste of the path — mirroring the clipboard + // screenshot flow (terminal-clipboard-paste.ts, issue #2842). Safe image + // paths are pasted raw, because escaping would corrupt the file-existence + // check those tools run on the pasted path. Image paths with spaces or shell + // metacharacters (`download (1).png`) are pasted escaped so a shell still + // gets one argument; agent TUIs undo that escaping before the check. + // Unframed, the escaped text reaches the TUI as typed keystrokes and stays + // plain text. Paths a paste frame would alter (control bytes) and non-image + // drops keep the original shell-escaped, space-separated typed input. // // Image payloads carry no trailing space of their own, so when an image is - // immediately followed by a non-image path the two would otherwise collide - // (`/repo/a.ts`). Add a single separating space in that - // case only — back-to-back image pastes are self-delimiting and a stray - // space between them would land in the TUI input. - const pathIsRawPasteImage = isImageDropPath(path) && canPasteImageDropPathRaw(path, targetShell) + // immediately followed by another path the two would otherwise collide + // (`/repo/a.ts`). Add a single separating space unless + // both are raw image pastes — those are self-delimiting for TUIs, and a + // stray space between them would land in the TUI input. Keep it when + // either side is escaped: a shell ignores paste boundaries, so + // `/a\ \(1\).png/b\ \(2\).png` would otherwise become one argument. + const imagePasteText = isImageDropPath(path) + ? formatImageDropPasteText(path, targetShell) + : null + const pathIsRawPasteImage = imagePasteText === path const nextPath = paths[index + 1] const nextPathIsRawPasteImage = nextPath !== undefined && isImageDropPath(nextPath) && canPasteImageDropPathRaw(nextPath, targetShell) - const needsSeparatorAfterImage = nextPath !== undefined && !nextPathIsRawPasteImage - const payload = pathIsRawPasteImage - ? separateImagePasteFromFollowingText( - wrapTerminalBracketedPasteText(path), - needsSeparatorAfterImage - ) - : `${shellEscapePath(path, targetShell)} ` + const needsSeparatorAfterImage = + nextPath !== undefined && !(pathIsRawPasteImage && nextPathIsRawPasteImage) + const payload = + imagePasteText !== null + ? separateImagePasteFromFollowingText( + wrapTerminalBracketedPasteText(imagePasteText), + needsSeparatorAfterImage + ) + : `${shellEscapePath(path, targetShell)} ` const writeResult = await runTerminalPasteOperationWithTimeout( () => writeTerminalPastePtyInput(liveTransport, payload, 'driving'), operationTimeoutMs