From 1b62f6511ea3495143fbbd87ecd4e302ef3c5ebd Mon Sep 17 00:00:00 2001 From: Rogerio Saulo Date: Mon, 22 Jun 2026 18:53:03 -0300 Subject: [PATCH] fix(terminal): attach dropped image files via bracketed paste (#6013) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): attach dropped image files via bracketed paste Dragging an image file into a terminal pane wrote the shell-escaped path as raw PTY input. Terminal TUIs (Claude Code, Codex, etc.) only turn a path into an image attachment when it arrives as a *bracketed paste*, so dropped images showed up as a literal path instead of an `[Image]` placeholder — unlike iTerm2/Warp, which wrap dropped paths in bracketed paste, and unlike Orca's own clipboard screenshot flow (#2842). Route dropped image files (by extension, mirroring IMAGE_MIME_TYPES) through `wrapTerminalBracketedPasteText` with the raw, un-escaped path so the file-existence check those tools run on the pasted path succeeds. Non-image drops keep the original shell-escaped, space-separated behaviour for use in shell commands. * fix(terminal): separate image paste from following non-image path A mixed drop where an image precedes a non-image path concatenated the two: the image bracketed-paste payload has no trailing space, so the next shell-escaped path was appended directly after it. Add a single separating space after an image payload only when the next path is a non-image — back-to-back image pastes are self-delimiting and a stray space between them would land in the TUI input. Co-Authored-By: Claude Opus 4.8 * Review terminal image drop path handling Co-authored-by: Orca --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Jinwoo-H Co-authored-by: Orca --- src/relay/fs-handler-utils.ts | 10 +- .../terminal-drop-image-path.test.ts | 38 +++++ .../terminal-pane/terminal-drop-image-path.ts | 47 ++++++ .../terminal-drop-path-writer.test.ts | 148 ++++++++++++++++++ .../terminal-drop-path-writer.ts | 29 +++- src/shared/image-file-extensions.ts | 12 ++ 6 files changed, 274 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-image-path.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-image-path.ts create mode 100644 src/shared/image-file-extensions.ts diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index f47be5a84..9a88c757f 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -14,6 +14,7 @@ import { ingestRgJsonLine, SEARCH_TIMEOUT_MS as SHARED_SEARCH_TIMEOUT_MS } from '../shared/text-search' +import { IMAGE_FILE_MIME_TYPES } from '../shared/image-file-extensions' import type { SearchResult as SharedSearchResult } from '../shared/types' // ─── Constants ─────────────────────────────────────────────────────── @@ -31,14 +32,7 @@ export const SEARCH_TIMEOUT_MS = SHARED_SEARCH_TIMEOUT_MS export const DEFAULT_MAX_RESULTS = 2000 export const IMAGE_MIME_TYPES: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.svg': 'image/svg+xml', - '.webp': 'image/webp', - '.bmp': 'image/bmp', - '.ico': 'image/x-icon', + ...IMAGE_FILE_MIME_TYPES, '.pdf': 'application/pdf' } 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 new file mode 100644 index 000000000..190142f1b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-drop-image-path.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { isImageDropPath } from './terminal-drop-image-path' + +describe('isImageDropPath', () => { + it('detects common image extensions case-insensitively', () => { + for (const path of [ + '/repo/shot.png', + '/repo/shot.PNG', + '/repo/a.jpg', + '/repo/a.jpeg', + '/repo/a.gif', + '/repo/icon.svg', + '/repo/a.webp', + '/repo/a.bmp', + '/repo/a.ico', + 'C:\\Users\\me\\Pictures\\diagram.PnG' + ]) { + expect(isImageDropPath(path)).toBe(true) + } + }) + + it('rejects non-image and extension-less paths', () => { + for (const path of [ + '/repo/index.ts', + '/repo/notes.md', + '/repo/archive.tar.gz', + '/repo/Makefile', + '/repo/.gitignore' + ]) { + expect(isImageDropPath(path)).toBe(false) + } + }) + + it('does not classify directory components with dots as images', () => { + expect(isImageDropPath('/home/jane.png/photo')).toBe(false) + expect(isImageDropPath('/home/jane.doe/screenshot')).toBe(false) + }) +}) 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 new file mode 100644 index 000000000..549d043ea --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-drop-image-path.ts @@ -0,0 +1,47 @@ +import { IMAGE_FILE_EXTENSIONS } from '../../../../shared/image-file-extensions' +import type { TerminalTargetShell } from './terminal-drop-shell' + +// Why: dropped image files should be handed to terminal TUIs (Claude Code, +// Codex, etc.) as image attachments, which those tools detect from a +// *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 = /["'`$;&|<>(){}[\]*?!#^%]/ + +/** + * Returns true when `path` looks like a local/remote image file based on its + * extension. Handles POSIX (`/`) and Windows (`\`) separators and is + * case-insensitive. The extension must be part of the basename, so directory + * components with dots (e.g. `/home/jane.doe/photo`) are not misclassified. + */ +export function isImageDropPath(path: string): boolean { + const lastDot = path.lastIndexOf('.') + if (lastDot === -1) { + return false + } + const lastSeparator = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + if (lastDot < lastSeparator) { + return false + } + return IMAGE_DROP_EXTENSIONS.has(path.slice(lastDot).toLowerCase()) +} + +export function canPasteImageDropPathRaw(path: string, targetShell: TerminalTargetShell): boolean { + if (hasControlByte(path)) { + return false + } + const unsafeRe = + targetShell === 'windows' ? WINDOWS_RAW_IMAGE_DROP_UNSAFE_RE : POSIX_RAW_IMAGE_DROP_UNSAFE_RE + return !unsafeRe.test(path) +} + +function hasControlByte(path: string): boolean { + for (let i = 0; i < path.length; i += 1) { + const code = path.charCodeAt(i) + if (code < 0x20 || code === 0x7f) { + return true + } + } + return false +} 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 f59b5066f..7fcb898d7 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 @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { wrapTerminalBracketedPasteText } from './terminal-bracketed-paste' import { writeTerminalDropPathsToCapturedTarget } from './terminal-drop-path-writer' function createTransport( @@ -51,6 +52,153 @@ describe('terminal drop path writer', () => { expect(sendInput).not.toHaveBeenCalled() }) + it('writes dropped image paths as a bracketed paste of the raw path', async () => { + const sendInput = vi.fn(() => true) + const sendInputAccepted = vi.fn(async () => true) + const { manager, pane } = createManager() + const transport = createTransport(sendInput, 'pty-1', sendInputAccepted) + + const result = 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/My Screenshot.png'], + targetShell: 'posix' + }) + + expect(result).toEqual({ sentAnyPath: true, targetCurrent: true, pathsWritten: 1 }) + // 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') + ) + }) + + it('keeps shell-escaped input for mixed image and non-image drops', 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.ts', '/repo/shot.png'], + targetShell: 'posix' + }) + + expect(sendInputAccepted).toHaveBeenNthCalledWith(1, '/repo/a.ts ') + expect(sendInputAccepted).toHaveBeenNthCalledWith( + 2, + wrapTerminalBracketedPasteText('/repo/shot.png') + ) + }) + + it('separates an 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/shot.png', '/repo/a.ts'], + targetShell: 'posix' + }) + + // Why: the image paste carries no trailing space of its own, so a following + // non-image path would collide with it without an explicit separator. + expect(sendInputAccepted).toHaveBeenNthCalledWith( + 1, + `${wrapTerminalBracketedPasteText('/repo/shot.png')} ` + ) + expect(sendInputAccepted).toHaveBeenNthCalledWith(2, '/repo/a.ts ') + }) + + it('does not insert a separator between back-to-back 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/one.png', '/repo/two.png'], + targetShell: 'posix' + }) + + // Why: bracketed pastes are self-delimiting; a stray space would land in the + // TUI input between the two attachments. + expect(sendInputAccepted).toHaveBeenNthCalledWith( + 1, + wrapTerminalBracketedPasteText('/repo/one.png') + ) + expect(sendInputAccepted).toHaveBeenNthCalledWith( + 2, + wrapTerminalBracketedPasteText('/repo/two.png') + ) + }) + + it('falls back to shell escaping for image paths with POSIX shell metacharacters', 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.png; touch /tmp/pwned #.png'], + targetShell: 'posix' + }) + + expect(sendInputAccepted).toHaveBeenCalledWith("'/repo/a.png; touch /tmp/pwned #.png' ") + }) + + it('falls back to shell escaping for image paths with Windows shell metacharacters', 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\\Pictures\\a&b.png'], + targetShell: 'windows' + }) + + expect(sendInputAccepted).toHaveBeenCalledWith('"C:\\Users\\me\\Pictures\\a&b.png" ') + }) + + it('separates an image paste from a following image path that must be shell escaped', 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/shot.png', '/repo/a.png; touch /tmp/pwned #.png'], + targetShell: 'posix' + }) + + expect(sendInputAccepted).toHaveBeenNthCalledWith( + 1, + `${wrapTerminalBracketedPasteText('/repo/shot.png')} ` + ) + expect(sendInputAccepted).toHaveBeenNthCalledWith(2, "'/repo/a.png; touch /tmp/pwned #.png' ") + }) + 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 0159d3e37..d97bca17a 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 @@ -1,6 +1,8 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager' 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 { type CapturedTerminalDropTarget, getCurrentTerminalDropTransport @@ -35,15 +37,38 @@ export async function writeTerminalDropPathsToCapturedTarget({ }): Promise { let sentAnyPath = false let pathsWritten = 0 - for (const path of paths) { + for (const [index, path] of paths.entries()) { // Why: acknowledged PTY writes are async, so a multi-path drop can outlive // the pane or PTY it originally targeted. const liveTransport = getCurrentTerminalDropTransport(manager, paneTransports, dropTarget) if (!liveTransport) { 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. + // + // 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) + const nextPath = paths[index + 1] + const nextPathIsRawPasteImage = + nextPath !== undefined && + isImageDropPath(nextPath) && + canPasteImageDropPathRaw(nextPath, targetShell) + const needsSeparatorAfterImage = nextPath !== undefined && !nextPathIsRawPasteImage + const payload = pathIsRawPasteImage + ? `${wrapTerminalBracketedPasteText(path)}${needsSeparatorAfterImage ? ' ' : ''}` + : `${shellEscapePath(path, targetShell)} ` const writeResult = await runTerminalPasteOperationWithTimeout( - () => writeTerminalPastePtyInput(liveTransport, `${shellEscapePath(path, targetShell)} `), + () => writeTerminalPastePtyInput(liveTransport, payload), operationTimeoutMs ) if (writeResult.timedOut) { diff --git a/src/shared/image-file-extensions.ts b/src/shared/image-file-extensions.ts new file mode 100644 index 000000000..c80496b40 --- /dev/null +++ b/src/shared/image-file-extensions.ts @@ -0,0 +1,12 @@ +export const IMAGE_FILE_MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon' +} + +export const IMAGE_FILE_EXTENSIONS = Object.freeze(Object.keys(IMAGE_FILE_MIME_TYPES))