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:
parent
b58478eae0
commit
1b62f6511e
|
|
@ -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'
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
Loading…
Reference in New Issue