mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
fix(terminal): attach dropped images whose filenames need escaping (#23707)
Every image drop is now sent to the terminal as a bracketed paste, so agent TUIs (Claude Code, Codex, Pi) attach it. Previously, names that needed shell escaping, such as `download (1).png`, and names with spaces, which Codex's shlex splits, were typed as keystrokes or pasted raw and stayed as text. Safe names are still pasted raw. Names with spaces or shell metacharacters are backslash-escaped inside the paste on POSIX shells, which Claude Code, Codex and pi-image-paste all unescape, apostrophes included. Windows shells keep double quotes. Non-ASCII characters such as the U+202F in macOS screenshot names stay bare. Names with control bytes are still typed. Fixes #23703 Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
@@ -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 = [
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
// (`<bracketed-paste>/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
|
||||
// (`<bracketed-paste>/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
|
||||
|
||||
Reference in New Issue
Block a user