mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(mobile): attach Codex images when agent identity is missing (#31370)
Use the shared desktop image-path formatter for unidentified mobile terminals and identified Codex, preserving owned agent identity, bracketed paste, upload routing and acknowledged-input ordering. Adapted from community PR #30310 by Wooseong Kim (@innocarpe). Fixes #30298. Validation includes exact-head CI and aggregate gate, native macOS and Windows consumer checks, actual paired Windows uploads with byte-integrity checks, and combined mobile/desktop tests, types, lint and bundle checks. Co-authored-by: Wooseong Kim <innocarpe@gmail.com>
This commit is contained in:
@@ -16,44 +16,6 @@
|
||||
"ordinal": 3,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"clipboard.startImageUpload\",\"params\":{\"expectedBase64Length\":32,\"connectionId\":\"connection-1\"}}"
|
||||
},
|
||||
"66b467a74aeb": {
|
||||
"name": "terminal.send#1",
|
||||
"ordinal": 9,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "terminal.send"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"enter": false,
|
||||
"terminal": "terminal-1",
|
||||
"text": "\u001b[200~@/tmp/img.png\u001b[201~ "
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-4",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"send": {
|
||||
"accepted": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"7c2e3ea286aa": {
|
||||
"name": "clipboard.startImageUpload#1",
|
||||
"ordinal": 2,
|
||||
@@ -105,6 +67,44 @@
|
||||
"ordinal": 7,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"clipboard.commitImageUpload\",\"params\":{\"uploadId\":\"upload-1\"}}"
|
||||
},
|
||||
"89d34592cd07": {
|
||||
"name": "terminal.send#1",
|
||||
"ordinal": 9,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "terminal.send"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"enter": false,
|
||||
"terminal": "terminal-1",
|
||||
"text": "\u001b[200~/tmp/img.png\u001b[201~ "
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-4",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"send": {
|
||||
"accepted": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"8aa742456efd": {
|
||||
"attached": false,
|
||||
"failure": {
|
||||
@@ -148,10 +148,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"bce8a3fd115b": {
|
||||
"b8f944bef6f2": {
|
||||
"name": "terminal.send#1",
|
||||
"ordinal": 10,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b[200~@/tmp/img.png\\u001b[201~ \",\"enter\":false}}"
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b[200~/tmp/img.png\\u001b[201~ \",\"enter\":false}}"
|
||||
},
|
||||
"f05e4695ffa3": {
|
||||
"name": "clipboard.appendImageUploadChunk#1",
|
||||
@@ -196,8 +196,8 @@
|
||||
{
|
||||
"id": "rejected",
|
||||
"observation": {
|
||||
"sender": ["7c2e3ea286aa", "f05e4695ffa3", "b50d2ec23283", "66b467a74aeb"],
|
||||
"payloads": ["6696a47819e1", "814ccd56a769", "8452698ac11a", "bce8a3fd115b"],
|
||||
"sender": ["7c2e3ea286aa", "f05e4695ffa3", "b50d2ec23283", "89d34592cd07"],
|
||||
"payloads": ["6696a47819e1", "814ccd56a769", "8452698ac11a", "b8f944bef6f2"],
|
||||
"settlements": {
|
||||
"anonymous": "7ed3d39f0607"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,14 @@
|
||||
"family": "clipboard.image-attachment",
|
||||
"namedDeltas": [],
|
||||
"values": {
|
||||
"080f28c67e30": {
|
||||
"08a3a6323626": {
|
||||
"name": "before-terminal-send",
|
||||
"ordinal": 8,
|
||||
"value": {
|
||||
"terminal": "terminal-1"
|
||||
}
|
||||
},
|
||||
"349431e2edcd": {
|
||||
"name": "terminal.send#1",
|
||||
"ordinal": 9,
|
||||
"args": [
|
||||
@@ -21,7 +28,7 @@
|
||||
},
|
||||
"enter": false,
|
||||
"terminal": "terminal-1",
|
||||
"text": "\u001b[200~@/tmp/img.png\u001b[201~ "
|
||||
"text": "\u001b[200~/tmp/img.png\u001b[201~ "
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -46,12 +53,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"08a3a6323626": {
|
||||
"name": "before-terminal-send",
|
||||
"ordinal": 8,
|
||||
"value": {
|
||||
"terminal": "terminal-1"
|
||||
}
|
||||
"5af5db426de7": {
|
||||
"name": "terminal.send#1",
|
||||
"ordinal": 10,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b[200~/tmp/img.png\\u001b[201~ \",\"enter\":false,\"client\":{\"id\":\"device-token-1\",\"type\":\"mobile\"}}}"
|
||||
},
|
||||
"6696a47819e1": {
|
||||
"name": "clipboard.startImageUpload#1",
|
||||
@@ -152,11 +157,6 @@
|
||||
"$rpc": "null"
|
||||
}
|
||||
},
|
||||
"e61d3d3476df": {
|
||||
"name": "terminal.send#1",
|
||||
"ordinal": 10,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-1\",\"text\":\"\\u001b[200~@/tmp/img.png\\u001b[201~ \",\"enter\":false,\"client\":{\"id\":\"device-token-1\",\"type\":\"mobile\"}}}"
|
||||
},
|
||||
"f05e4695ffa3": {
|
||||
"name": "clipboard.appendImageUploadChunk#1",
|
||||
"ordinal": 4,
|
||||
@@ -200,8 +200,8 @@
|
||||
{
|
||||
"id": "attached",
|
||||
"observation": {
|
||||
"sender": ["7c2e3ea286aa", "f05e4695ffa3", "b50d2ec23283", "080f28c67e30"],
|
||||
"payloads": ["6696a47819e1", "814ccd56a769", "8452698ac11a", "e61d3d3476df"],
|
||||
"sender": ["7c2e3ea286aa", "f05e4695ffa3", "b50d2ec23283", "349431e2edcd"],
|
||||
"payloads": ["6696a47819e1", "814ccd56a769", "8452698ac11a", "5af5db426de7"],
|
||||
"settlements": {
|
||||
"normal": "84e5ca07cb7a"
|
||||
},
|
||||
|
||||
@@ -15971,7 +15971,7 @@
|
||||
"complete": "terminal.send#1",
|
||||
"params": {
|
||||
"terminal": "terminal-1",
|
||||
"text": "\u001b[200~@/tmp/img.png\u001b[201~ ",
|
||||
"text": "\u001b[200~/tmp/img.png\u001b[201~ ",
|
||||
"enter": false,
|
||||
"client": {
|
||||
"id": "device-token-1",
|
||||
@@ -16122,7 +16122,7 @@
|
||||
"complete": "terminal.send#1",
|
||||
"params": {
|
||||
"terminal": "terminal-1",
|
||||
"text": "\u001b[200~@/tmp/img.png\u001b[201~ ",
|
||||
"text": "\u001b[200~/tmp/img.png\u001b[201~ ",
|
||||
"enter": false
|
||||
},
|
||||
"reply": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
buildMobileImagePastePayload,
|
||||
buildMobileTerminalImagePastePayload,
|
||||
computeMobileClipboardImageDownscale,
|
||||
MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS,
|
||||
normalizeMobileClipboardImageBase64,
|
||||
@@ -157,6 +158,43 @@ describe('mobile clipboard image paste helpers', () => {
|
||||
'\x1b[200~/tmp/\u241b.png\x1b[201~'
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes spaced paths for Codex and unidentified terminals', () => {
|
||||
const escaped = '\x1b[200~/tmp/or\\ ca.png\x1b[201~'
|
||||
expect(buildMobileTerminalImagePastePayload('/tmp/or ca.png', 'codex')).toBe(escaped)
|
||||
expect(buildMobileTerminalImagePastePayload('/tmp/or ca.png', null)).toBe(escaped)
|
||||
expect(buildMobileTerminalImagePastePayload('/tmp/or ca.png', undefined)).toBe(escaped)
|
||||
expect(buildMobileTerminalImagePastePayload('/tmp/or ca.png', 'unknown')).toBe(escaped)
|
||||
expect(buildMobileTerminalImagePastePayload('/tmp/\x1b.png', null)).toBe(
|
||||
'\x1b[200~/tmp/\u241b.png\x1b[201~'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps file mentions for agents that need them', () => {
|
||||
const mention = '\x1b[200~@"/tmp/or ca.png"\x1b[201~'
|
||||
expect(buildMobileTerminalImagePastePayload('/tmp/or ca.png', 'opencode')).toBe(mention)
|
||||
expect(buildMobileImagePastePayload('/tmp/or ca.png', null)).toBe(mention)
|
||||
})
|
||||
|
||||
it.each([null, 'unknown', 'codex', 'omp'])(
|
||||
'contains embedded paste markers inside the image paste for %s',
|
||||
(agent) => {
|
||||
const payload = buildMobileTerminalImagePastePayload(
|
||||
'/tmp/image\x1b[201~\ncommand\x1b[200~.png',
|
||||
agent
|
||||
)
|
||||
expect(payload.split('\x1b')).toHaveLength(3)
|
||||
expect(payload.startsWith('\x1b[200~')).toBe(true)
|
||||
expect(payload.endsWith('\x1b[201~')).toBe(true)
|
||||
expect(payload).toContain('image\u241b[201~\ncommand\u241b[200~.png')
|
||||
}
|
||||
)
|
||||
|
||||
it('quotes Windows paths with spaces using the host path dialect', () => {
|
||||
expect(
|
||||
buildMobileTerminalImagePastePayload('C:\\Users\\Jane Doe\\Temp\\orca-paste.png', null)
|
||||
).toBe('\x1b[200~"C:\\Users\\Jane Doe\\Temp\\orca-paste.png"\x1b[201~')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mobile clipboard image downscaling', () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isWindowsAbsolutePathLike } from '../../../src/shared/cross-platform-path'
|
||||
import { formatImageDropPasteText } from '../../../src/shared/image-path-paste'
|
||||
import { formatAgentImagePath } from '../../../src/shared/agent-image-paste'
|
||||
import {
|
||||
computeMobileClipboardImageDownscale,
|
||||
@@ -156,5 +158,25 @@ export function buildMobileImagePastePayload(filePath: string, agent?: string |
|
||||
// Why: generated image paths are paste payloads, not ordinary typed input.
|
||||
// Bracket the path even when it is one line so agents receive it atomically
|
||||
// and stale terminal paste state cannot turn it into shell commands.
|
||||
return `\x1b[200~${formatAgentImagePath(agent, filePath).split('\x1b').join('\u241b')}\x1b[201~`
|
||||
return bracketMobileImagePaste(formatAgentImagePath(agent, filePath))
|
||||
}
|
||||
|
||||
// Match desktop terminal paste when no agent identity is available yet.
|
||||
export function buildMobileTerminalImagePastePayload(
|
||||
filePath: string,
|
||||
agent?: string | null
|
||||
): string {
|
||||
const id = agent?.trim()
|
||||
if (!id || id === 'unknown' || id === 'codex') {
|
||||
const pasted = formatImageDropPasteText(
|
||||
filePath,
|
||||
isWindowsAbsolutePathLike(filePath) ? 'windows' : 'posix'
|
||||
)
|
||||
return bracketMobileImagePaste(pasted ?? filePath)
|
||||
}
|
||||
return buildMobileImagePastePayload(filePath, id)
|
||||
}
|
||||
|
||||
function bracketMobileImagePaste(pasted: string): string {
|
||||
return `\x1b[200~${pasted.split('\x1b').join('\u241b')}\x1b[201~`
|
||||
}
|
||||
|
||||
@@ -25,6 +25,40 @@ function clientWithResponses(responses: RpcResponse[]): Pick<RpcClient, 'sendReq
|
||||
}
|
||||
|
||||
describe('attachMobileImageToTerminal', () => {
|
||||
it.each([null, 'unknown', 'codex'])(
|
||||
'uploads an image as an attachment path when terminal agent is %s',
|
||||
async (agent) => {
|
||||
const client = clientWithResponses([
|
||||
ok('start', { uploadId: 'upload-1' }),
|
||||
ok('append', { received: 4 }),
|
||||
ok('commit', '/tmp/codex-shot.png'),
|
||||
ok('send', { send: { accepted: true } })
|
||||
])
|
||||
|
||||
expect(
|
||||
await attachMobileImageToTerminal('library', {
|
||||
client,
|
||||
agent,
|
||||
terminal: 'codex-term',
|
||||
deviceToken: null,
|
||||
getConnectionId: async () => 'connection-ssh',
|
||||
pickImage: async () => ({ base64: 'AAAA' })
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
client.calls.find((call) => call.method === 'clipboard.startImageUpload')?.params
|
||||
).toMatchObject({ connectionId: 'connection-ssh' })
|
||||
expect(client.calls.at(-1)).toEqual({
|
||||
method: 'terminal.send',
|
||||
params: {
|
||||
terminal: 'codex-term',
|
||||
text: '\x1b[200~/tmp/codex-shot.png\x1b[201~ ',
|
||||
enter: false
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the captured OMP format while the picker is pending', async () => {
|
||||
const client = clientWithResponses([
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { separateImagePasteFromFollowingText } from '../../../src/shared/image-paste-following-text'
|
||||
import {
|
||||
buildMobileImagePastePayload,
|
||||
buildMobileTerminalImagePastePayload,
|
||||
saveMobileClipboardImageAsTempFile
|
||||
} from './mobile-clipboard-image'
|
||||
import type { MobileImageSource, PickedMobileImage } from './mobile-image-source-picker'
|
||||
@@ -54,7 +54,7 @@ export async function attachMobileImageToTerminal(
|
||||
// next keystroke would otherwise glue onto the path (`…pngadd`). Unlike native
|
||||
// chat there is no batch to look ahead in, and a trailing space is inert.
|
||||
const payload = separateImagePasteFromFollowingText(
|
||||
buildMobileImagePastePayload(imagePath, agent),
|
||||
buildMobileTerminalImagePastePayload(imagePath, agent),
|
||||
true
|
||||
)
|
||||
if (beforeTerminalSend && !(await beforeTerminalSend(terminal))) {
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { TuiAgent } from '../../../src/shared/tui-agent'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import type { TerminalLiveInputSender } from '../terminal/terminal-live-input-sender'
|
||||
import { useTerminalLiveInputCommit } from '../terminal/use-terminal-live-input-commit'
|
||||
import { attachMobileImageToTerminal } from './mobile-image-attachment'
|
||||
import { resolveMobileImageAttachmentAgent } from './mobile-image-paste-agent'
|
||||
import { useMobileAttachmentInputLeaseGate } from './use-mobile-attachment-input-lease-gate'
|
||||
import { useMobileSessionTerminalSendActions } from './use-mobile-session-terminal-send-actions'
|
||||
import { useMobileTerminalPaste } from './use-mobile-terminal-paste'
|
||||
|
||||
vi.mock('react-native', () => ({ Keyboard: { dismiss: vi.fn() } }))
|
||||
vi.mock('../platform/haptics', () => ({ triggerError: vi.fn() }))
|
||||
vi.mock('expo-clipboard', () => ({
|
||||
getStringAsync: async () => '',
|
||||
getImageAsync: async () => ({ data: 'AAAA', size: { width: 1, height: 1 } })
|
||||
}))
|
||||
vi.mock('expo-file-system', () => ({ File: class {}, Paths: {} }))
|
||||
|
||||
const ref = <T,>(current: T) => ({ current })
|
||||
const renderers: ReactTestRenderer[] = []
|
||||
const imagePayload = '\x1b[200~/tmp/orca-image.png\x1b[201~ '
|
||||
|
||||
function fixture(title = 'Terminal', imagePath = '/tmp/orca-image.png', launchAgent?: TuiAgent) {
|
||||
const agent = resolveMobileImageAttachmentAgent(
|
||||
{ type: 'terminal', id: 'tab-1', title, terminal: 'term-1', isActive: true, launchAgent },
|
||||
null
|
||||
)
|
||||
const sends: Array<{ terminal: unknown; text: unknown; enter: unknown }> = []
|
||||
let resolveText!: (value: RpcResponse) => void
|
||||
let textStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => {
|
||||
textStarted = resolve
|
||||
})
|
||||
const ok = (result: unknown): RpcResponse => ({ id: 'rpc', ok: true, result })
|
||||
const client: RpcClient = {
|
||||
sendRequest: vi.fn(async (method, params) => {
|
||||
if (method === 'clipboard.startImageUpload') {
|
||||
return ok({ uploadId: 'upload' })
|
||||
}
|
||||
if (method === 'clipboard.appendImageUploadChunk') {
|
||||
return ok({ receivedBase64Length: 4 })
|
||||
}
|
||||
if (method === 'clipboard.commitImageUpload') {
|
||||
return ok(imagePath)
|
||||
}
|
||||
if (method === 'terminal.send') {
|
||||
if (!params || typeof params !== 'object' || !('text' in params)) {
|
||||
throw new Error('terminal send requires text')
|
||||
}
|
||||
if (!('terminal' in params) || !('enter' in params)) {
|
||||
throw new Error('terminal send requires target and enter')
|
||||
}
|
||||
sends.push({ terminal: params.terminal, text: params.text, enter: params.enter })
|
||||
if (params.text === '한글') {
|
||||
textStarted()
|
||||
return new Promise<RpcResponse>((resolve) => {
|
||||
resolveText = resolve
|
||||
})
|
||||
}
|
||||
return ok({ send: { accepted: true } })
|
||||
}
|
||||
if (method === 'orchestration.workerTerminalUserInput') {
|
||||
return ok({ changed: 1 })
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`)
|
||||
}),
|
||||
subscribe: () => () => {},
|
||||
updateTerminalSubscriptionViewport: vi.fn(),
|
||||
getState: () => 'connected',
|
||||
getReconnectAttempt: () => 0,
|
||||
getLastConnectedAt: () => null,
|
||||
onStateChange: () => () => {},
|
||||
notifyForeground: vi.fn(),
|
||||
close: vi.fn()
|
||||
}
|
||||
const activeHandleRef = ref<string | null>('term-1')
|
||||
const activeSessionTabTypeRef = ref<string | null>('terminal')
|
||||
const liveInputRef = ref(null)
|
||||
const sendLiveTerminalInputRef = ref<TerminalLiveInputSender>(async () => false)
|
||||
const scope = {
|
||||
client,
|
||||
clientRef: ref(client),
|
||||
activeHandle: 'term-1',
|
||||
activeHandleRef,
|
||||
activeSessionTabTypeRef,
|
||||
connState: 'connected' as const,
|
||||
connStateRef: ref('connected' as const),
|
||||
canSend: true,
|
||||
deviceTokenRef: ref('phone'),
|
||||
liveInputRef,
|
||||
commandInputRef: ref(null),
|
||||
liveInputFocusTimerRef: ref(null),
|
||||
sendLiveTerminalInputRef,
|
||||
showToast: vi.fn()
|
||||
}
|
||||
let live!: ReturnType<typeof useTerminalLiveInputCommit>
|
||||
let paste!: ReturnType<typeof useMobileTerminalPaste>
|
||||
let beforeTerminalSend!: (target: string) => Promise<boolean>
|
||||
function Harness() {
|
||||
live = useTerminalLiveInputCommit({
|
||||
activeHandle: 'term-1',
|
||||
activeHandleRef,
|
||||
activeSessionTabType: 'terminal',
|
||||
activeSessionTabTypeRef,
|
||||
connected: true,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles: new Set(['term-1']),
|
||||
liveInputTerminalHandlesRef: ref(new Set(['term-1'])),
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture: vi.fn()
|
||||
})
|
||||
useMobileSessionTerminalSendActions(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: live sending reads the supplied refs; unrelated action-sheet and buffered-submit callbacks are not invoked.
|
||||
scope as unknown as Parameters<typeof useMobileSessionTerminalSendActions>[0]
|
||||
)
|
||||
beforeTerminalSend = useMobileAttachmentInputLeaseGate({
|
||||
...scope,
|
||||
flushPendingLiveInputBeforeExternalSend: live.flushPendingLiveInputBeforeExternalSend,
|
||||
nativeChatInputLeaseReadyRef: ref(true)
|
||||
})
|
||||
paste = useMobileTerminalPaste({
|
||||
...scope,
|
||||
agent,
|
||||
flushPendingLiveInputBeforeExternalSend: live.flushPendingLiveInputBeforeExternalSend,
|
||||
getActiveWorktreeConnectionId: async () => null,
|
||||
onError: vi.fn(),
|
||||
onSuccess: vi.fn(),
|
||||
ptyModesRef: ref(new Map()),
|
||||
refreshCanPaste: vi.fn()
|
||||
})
|
||||
return null
|
||||
}
|
||||
act(() => {
|
||||
renderers.push(create(createElement(Harness)))
|
||||
})
|
||||
return {
|
||||
sends,
|
||||
started,
|
||||
activeHandleRef,
|
||||
change: (text: string) => live.handleLiveInputChange({ nativeEvent: { text } }),
|
||||
flush: () => live.flushPendingLiveInputBeforeExternalSend('term-1'),
|
||||
acceptText: (accepted: boolean) => resolveText(ok({ send: { accepted } })),
|
||||
attach: (entry: 'picker' | 'clipboard') =>
|
||||
entry === 'clipboard'
|
||||
? paste()
|
||||
: attachMobileImageToTerminal('library', {
|
||||
client,
|
||||
terminal: 'term-1',
|
||||
deviceToken: 'phone',
|
||||
agent,
|
||||
pickImage: async () => ({ base64: 'AAAA' }),
|
||||
getConnectionId: async () => null,
|
||||
beforeTerminalSend
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
for (const renderer of renderers.splice(0)) {
|
||||
renderer.unmount()
|
||||
}
|
||||
})
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it.each(['picker', 'clipboard'] as const)(
|
||||
'%s waits for composed text acknowledgement and starts a fresh mirror after the image',
|
||||
async (entry) => {
|
||||
const test = fixture()
|
||||
for (const text of ['ㅎ', '하', '한', '한ㄱ', '한그', '한글']) {
|
||||
test.change(text)
|
||||
}
|
||||
expect(test.sends).toEqual([])
|
||||
const attachment = test.attach(entry)
|
||||
await test.started
|
||||
expect(test.sends).toEqual([{ terminal: 'term-1', text: '한글', enter: false }])
|
||||
test.acceptText(true)
|
||||
await attachment
|
||||
test.change('next')
|
||||
await test.flush()
|
||||
expect(test.sends).toEqual([
|
||||
{ terminal: 'term-1', text: '한글', enter: false },
|
||||
{ terminal: 'term-1', text: imagePayload, enter: false },
|
||||
{ terminal: 'term-1', text: 'next', enter: false }
|
||||
])
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(test.sends).toHaveLength(3)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['picker', 'clipboard'] as const)(
|
||||
'%s suppresses the image when the real live sender refuses the pending text',
|
||||
async (entry) => {
|
||||
const test = fixture()
|
||||
test.change('한글')
|
||||
const attachment = test.attach(entry)
|
||||
await test.started
|
||||
test.acceptText(false)
|
||||
await attachment
|
||||
expect(test.sends).toEqual([{ terminal: 'term-1', text: '한글', enter: false }])
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['picker', 'clipboard'] as const)(
|
||||
'%s suppresses the image if the target changes while the real live send is pending',
|
||||
async (entry) => {
|
||||
const test = fixture()
|
||||
test.change('한글')
|
||||
const attachment = test.attach(entry)
|
||||
await test.started
|
||||
test.activeHandleRef.current = 'term-2'
|
||||
test.acceptText(true)
|
||||
await attachment
|
||||
expect(test.sends).toEqual([{ terminal: 'term-1', text: '한글', enter: false }])
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['picker', 'clipboard'] as const)(
|
||||
'%s keeps anonymous image attachment bytes when the terminal display title names another agent',
|
||||
async (entry) => {
|
||||
const test = fixture('OpenCode')
|
||||
await test.attach(entry)
|
||||
expect(test.sends).toEqual([{ terminal: 'term-1', text: imagePayload, enter: false }])
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['picker', undefined],
|
||||
['clipboard', undefined],
|
||||
['picker', 'codex'],
|
||||
['clipboard', 'codex']
|
||||
] as const)('%s escapes a spaced host temp path with agent %s', async (entry, launchAgent) => {
|
||||
const test = fixture('Terminal', '/tmp/orca images/orca.png', launchAgent)
|
||||
await test.attach(entry)
|
||||
expect(test.sends).toEqual([
|
||||
{ terminal: 'term-1', text: '\x1b[200~/tmp/orca\\ images/orca.png\x1b[201~ ', enter: false }
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveMobileImageAttachmentAgent } from './mobile-image-paste-agent'
|
||||
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
|
||||
import type { MobileSessionTab } from './mobile-session-route-types'
|
||||
|
||||
function status(agentType: string): AgentStatusEntry {
|
||||
return {
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
updatedAt: 1,
|
||||
stateStartedAt: 1,
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
agentType,
|
||||
stateHistory: []
|
||||
}
|
||||
}
|
||||
|
||||
function terminalTab(overrides: Partial<Extract<MobileSessionTab, { type: 'terminal' }>> = {}) {
|
||||
return {
|
||||
type: 'terminal',
|
||||
id: 'tab-1',
|
||||
title: 'Terminal',
|
||||
terminal: 'term-1',
|
||||
isActive: true,
|
||||
...overrides
|
||||
} satisfies MobileSessionTab
|
||||
}
|
||||
|
||||
describe('resolveMobileImageAttachmentAgent', () => {
|
||||
it('prefers the hook-reported agent', () => {
|
||||
const tab = terminalTab({
|
||||
agentStatus: status('codex'),
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
expect(resolveMobileImageAttachmentAgent(tab, null)).toBe('codex')
|
||||
})
|
||||
|
||||
it('uses the launched agent before the first hook status arrives', () => {
|
||||
expect(resolveMobileImageAttachmentAgent(terminalTab({ launchAgent: 'codex' }), null)).toBe(
|
||||
'codex'
|
||||
)
|
||||
})
|
||||
|
||||
it('skips an unknown hook agent and falls back to the launched agent', () => {
|
||||
const tab = terminalTab({
|
||||
agentStatus: status('unknown'),
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
expect(resolveMobileImageAttachmentAgent(tab, null)).toBe('codex')
|
||||
})
|
||||
|
||||
it('ignores display-only titles when no agent identity is owned', () => {
|
||||
expect(resolveMobileImageAttachmentAgent(terminalTab({ title: 'OpenCode' }), null)).toBe(null)
|
||||
})
|
||||
|
||||
it('falls back to the native chat agent when the terminal names none', () => {
|
||||
expect(resolveMobileImageAttachmentAgent(terminalTab(), 'claude')).toBe('claude')
|
||||
expect(resolveMobileImageAttachmentAgent(terminalTab(), null)).toBeNull()
|
||||
})
|
||||
|
||||
it('uses the native chat agent for non-terminal tabs', () => {
|
||||
expect(resolveMobileImageAttachmentAgent(null, 'codex')).toBe('codex')
|
||||
})
|
||||
|
||||
it('keeps terminal identity ahead of conflicting title and native chat identity', () => {
|
||||
expect(
|
||||
resolveMobileImageAttachmentAgent(
|
||||
terminalTab({ title: 'Codex ready', agentStatus: status(' omp '), launchAgent: 'claude' }),
|
||||
'gemini'
|
||||
)
|
||||
).toBe('omp')
|
||||
expect(
|
||||
resolveMobileImageAttachmentAgent(
|
||||
terminalTab({ title: 'Codex ready', agentStatus: status('unknown'), launchAgent: 'omp' }),
|
||||
'claude'
|
||||
)
|
||||
).toBe('omp')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { resolveMobileTerminalTabOwnedAgentId } from './mobile-terminal-tab-agent'
|
||||
import type { MobileSessionTab } from './mobile-session-route-types'
|
||||
|
||||
export function resolveMobileImageAttachmentAgent(
|
||||
tab: MobileSessionTab | null | undefined,
|
||||
nativeChatAgent: string | null
|
||||
): string | null {
|
||||
if (tab?.type === 'terminal') {
|
||||
return resolveMobileTerminalTabOwnedAgentId(tab) ?? nativeChatAgent
|
||||
}
|
||||
return nativeChatAgent
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react'
|
||||
import { AppState, type AppStateStatus } from 'react-native'
|
||||
import { useClipboardReader } from '../platform/clipboard'
|
||||
import { triggerSelection, triggerError } from '../platform/haptics'
|
||||
import { resolveMobileImageAttachmentAgent } from './mobile-image-paste-agent'
|
||||
import { useMobileNewTabAgentOptions } from './use-mobile-new-tab-agent-options'
|
||||
import { useMobileSessionImageAttachments } from './use-mobile-session-image-attachments'
|
||||
import { useMobileNativeChatStructuredComposer } from './use-mobile-native-chat-structured-composer'
|
||||
@@ -45,10 +46,10 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti
|
||||
hostId
|
||||
} = scope
|
||||
const clipboardContents = useClipboardReader().contents
|
||||
const agent =
|
||||
activeSessionTab && 'agentStatus' in activeSessionTab
|
||||
? (activeSessionTab.agentStatus?.agentType ?? nativeChatController.nativeChatAgent)
|
||||
: nativeChatController.nativeChatAgent
|
||||
const agent = resolveMobileImageAttachmentAgent(
|
||||
activeSessionTab,
|
||||
nativeChatController.nativeChatAgent
|
||||
)
|
||||
const handlePaste = useMobileTerminalPaste({
|
||||
client,
|
||||
agent,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import { useMobileTerminalPaste } from './use-mobile-terminal-paste'
|
||||
|
||||
// `useMemo` joins `useCallback` because the paste now reads the clipboard through the platform
|
||||
@@ -26,45 +27,114 @@ vi.mock('./mobile-clipboard-image', async (importOriginal) => ({
|
||||
}))
|
||||
|
||||
describe('mobile clipboard image followed by typing', () => {
|
||||
it.each(['omp', 'claude'])('separates a pasted image from subsequent %s input', async (agent) => {
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, result: { send: { accepted: true } } })
|
||||
const client: RpcClient = {
|
||||
sendRequest,
|
||||
subscribe: () => () => {},
|
||||
updateTerminalSubscriptionViewport: vi.fn(),
|
||||
getState: () => 'connected',
|
||||
getReconnectAttempt: () => 0,
|
||||
getLastConnectedAt: () => null,
|
||||
onStateChange: () => () => {},
|
||||
notifyForeground: vi.fn(),
|
||||
close: vi.fn()
|
||||
it.each(['omp', 'claude', 'codex', null, 'unknown'])(
|
||||
'separates a pasted image from subsequent %s input',
|
||||
async (agent) => {
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, result: { send: { accepted: true } } })
|
||||
const client: RpcClient = {
|
||||
sendRequest,
|
||||
subscribe: () => () => {},
|
||||
updateTerminalSubscriptionViewport: vi.fn(),
|
||||
getState: () => 'connected',
|
||||
getReconnectAttempt: () => 0,
|
||||
getLastConnectedAt: () => null,
|
||||
onStateChange: () => () => {},
|
||||
notifyForeground: vi.fn(),
|
||||
close: vi.fn()
|
||||
}
|
||||
const paste = useMobileTerminalPaste({
|
||||
agent,
|
||||
activeHandle: 'term-1',
|
||||
activeHandleRef: { current: 'term-1' },
|
||||
activeSessionTabTypeRef: { current: 'terminal' },
|
||||
canSend: true,
|
||||
client,
|
||||
clientRef: { current: client },
|
||||
connState: 'connected',
|
||||
connStateRef: { current: 'connected' },
|
||||
deviceTokenRef: { current: null },
|
||||
flushPendingLiveInputBeforeExternalSend: async () => true,
|
||||
getActiveWorktreeConnectionId: async () => null,
|
||||
onError: vi.fn(),
|
||||
onSuccess: vi.fn(),
|
||||
ptyModesRef: { current: new Map() },
|
||||
refreshCanPaste: vi.fn(),
|
||||
showToast: vi.fn()
|
||||
})
|
||||
await paste()
|
||||
expect(sendRequest).toHaveBeenCalledWith('terminal.send', {
|
||||
terminal: 'term-1',
|
||||
text: `\x1b[200~${agent === 'omp' ? '@' : ''}/tmp/shot.png\x1b[201~ `,
|
||||
enter: false
|
||||
})
|
||||
}
|
||||
const paste = useMobileTerminalPaste({
|
||||
agent,
|
||||
activeHandle: 'term-1',
|
||||
activeHandleRef: { current: 'term-1' },
|
||||
activeSessionTabTypeRef: { current: 'terminal' },
|
||||
canSend: true,
|
||||
client,
|
||||
clientRef: { current: client },
|
||||
connState: 'connected',
|
||||
connStateRef: { current: 'connected' },
|
||||
deviceTokenRef: { current: null },
|
||||
flushPendingLiveInputBeforeExternalSend: async () => true,
|
||||
getActiveWorktreeConnectionId: async () => null,
|
||||
onError: vi.fn(),
|
||||
onSuccess: vi.fn(),
|
||||
ptyModesRef: { current: new Map() },
|
||||
refreshCanPaste: vi.fn(),
|
||||
showToast: vi.fn()
|
||||
})
|
||||
await paste()
|
||||
expect(sendRequest).toHaveBeenCalledWith('terminal.send', {
|
||||
terminal: 'term-1',
|
||||
text: `\x1b[200~${agent === 'omp' ? '@' : ''}/tmp/shot.png\x1b[201~ `,
|
||||
enter: false
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
it.each(['terminal switch', 'non-terminal view', 'disconnect'])(
|
||||
'drops a completed image paste after a pending input flush observes %s',
|
||||
async (change) => {
|
||||
const sendRequest = vi.fn()
|
||||
const client: RpcClient = {
|
||||
sendRequest,
|
||||
subscribe: () => () => {},
|
||||
updateTerminalSubscriptionViewport: vi.fn(),
|
||||
getState: () => 'connected',
|
||||
getReconnectAttempt: () => 0,
|
||||
getLastConnectedAt: () => null,
|
||||
onStateChange: () => () => {},
|
||||
notifyForeground: vi.fn(),
|
||||
close: vi.fn()
|
||||
}
|
||||
const activeHandleRef = { current: 'term-1' }
|
||||
const activeSessionTabTypeRef = { current: 'terminal' }
|
||||
const connStateRef: { current: ConnectionState } = { current: 'connected' }
|
||||
let releaseFlush!: (ready: boolean) => void
|
||||
let flushStarted!: () => void
|
||||
const flushing = new Promise<void>((resolve) => {
|
||||
flushStarted = resolve
|
||||
})
|
||||
const onSuccess = vi.fn()
|
||||
const paste = useMobileTerminalPaste({
|
||||
agent: null,
|
||||
activeHandle: 'term-1',
|
||||
activeHandleRef,
|
||||
activeSessionTabTypeRef,
|
||||
canSend: true,
|
||||
client,
|
||||
clientRef: { current: client },
|
||||
connState: 'connected',
|
||||
connStateRef,
|
||||
deviceTokenRef: { current: null },
|
||||
flushPendingLiveInputBeforeExternalSend: () => {
|
||||
flushStarted()
|
||||
return new Promise<boolean>((resolve) => {
|
||||
releaseFlush = resolve
|
||||
})
|
||||
},
|
||||
getActiveWorktreeConnectionId: async () => null,
|
||||
onError: vi.fn(),
|
||||
onSuccess,
|
||||
ptyModesRef: { current: new Map() },
|
||||
refreshCanPaste: vi.fn(),
|
||||
showToast: vi.fn()
|
||||
})
|
||||
const pending = paste()
|
||||
await flushing
|
||||
if (change === 'terminal switch') {
|
||||
activeHandleRef.current = 'term-2'
|
||||
}
|
||||
if (change === 'non-terminal view') {
|
||||
activeSessionTabTypeRef.current = 'agent-session'
|
||||
}
|
||||
if (change === 'disconnect') {
|
||||
connStateRef.current = 'disconnected'
|
||||
}
|
||||
releaseFlush(true)
|
||||
await pending
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { TerminalModes } from '../terminal/terminal-webview-contract'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import {
|
||||
buildMobileImagePastePayload,
|
||||
buildMobileTerminalImagePastePayload,
|
||||
prepareMobileClipboardImageBase64,
|
||||
saveMobileClipboardImageAsTempFile
|
||||
} from './mobile-clipboard-image'
|
||||
@@ -93,7 +93,7 @@ export function useMobileTerminalPaste({
|
||||
connectionId
|
||||
})
|
||||
payload = separateImagePasteFromFollowingText(
|
||||
buildMobileImagePastePayload(imagePath, agent),
|
||||
buildMobileTerminalImagePastePayload(imagePath, agent),
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { quotePosixShellUnlessPlain } from '../../../../shared/shell-argument-quoting'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import { focusPanePreservingOverlays } from '@/lib/pane-manager/pane-overlay-focus'
|
||||
|
||||
@@ -67,14 +66,4 @@ export function shouldPreserveEditableFocus(element: Element | null): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
// Why: escape rules are a property of the *target* shell receiving the path,
|
||||
// not the client OS. A Windows client dropping onto a Linux SSH worktree must
|
||||
// produce POSIX-quoted output; passing a userAgent string here coupled escape
|
||||
// rules to the client and silently misquoted cross-platform SSH drops.
|
||||
export function shellEscapePath(path: string, targetShell: 'posix' | 'windows'): string {
|
||||
if (targetShell === 'windows') {
|
||||
return /^[a-zA-Z0-9_./@:\\-]+$/.test(path) ? path : `"${path}"`
|
||||
}
|
||||
|
||||
return quotePosixShellUnlessPlain(path)
|
||||
}
|
||||
export { shellEscapePath } from '../../../../shared/shell-path-quoting'
|
||||
|
||||
@@ -1,74 +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,
|
||||
// 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)
|
||||
// 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
|
||||
* 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
if (code < 0x20 || code === 0x7f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
export {
|
||||
canPasteImageDropPathRaw,
|
||||
formatImageDropPasteText,
|
||||
isImageDropPath
|
||||
} from '../../../../shared/image-path-paste'
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { IMAGE_FILE_EXTENSIONS } from './image-file-extensions'
|
||||
import { shellEscapePath } from './shell-path-quoting'
|
||||
|
||||
type TerminalTargetShell = 'posix' | 'windows'
|
||||
|
||||
// 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)
|
||||
// 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
|
||||
* 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
if (code < 0x20 || code === 0x7f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { quotePosixShellUnlessPlain } from './shell-argument-quoting'
|
||||
|
||||
// Why: escape rules are a property of the *target* shell receiving the path,
|
||||
// not the client OS. A Windows client dropping onto a Linux SSH worktree must
|
||||
// produce POSIX-quoted output; passing a userAgent string here coupled escape
|
||||
// rules to the client and silently misquoted cross-platform SSH drops.
|
||||
export function shellEscapePath(path: string, targetShell: 'posix' | 'windows'): string {
|
||||
if (targetShell === 'windows') {
|
||||
return /^[a-zA-Z0-9_./@:\\-]+$/.test(path) ? path : `"${path}"`
|
||||
}
|
||||
|
||||
return quotePosixShellUnlessPlain(path)
|
||||
}
|
||||
Reference in New Issue
Block a user