fix(terminal): attach dropped image files via bracketed paste (#6013)

* 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 <noreply@anthropic.com>

* Review terminal image drop path handling

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Rogerio Saulo 2026-06-22 18:53:03 -03:00 committed by GitHub
parent b58478eae0
commit 1b62f6511e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 274 additions and 10 deletions

View File

@ -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<string, string> = {
'.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'
}

View File

@ -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)
})
})

View File

@ -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
}

View File

@ -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 {

View File

@ -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<TerminalDropPathWriteResult> {
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
// (`<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)
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) {

View File

@ -0,0 +1,12 @@
export const IMAGE_FILE_MIME_TYPES: Record<string, string> = {
'.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))