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:
Neil
2026-10-21 15:31:55 -07:00
committed by GitHub
co-authored by Wooseong Kim
parent 7b1d7538c1
commit 2b716f601d
17 changed files with 702 additions and 194 deletions
@@ -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"
},
+2 -2
View File
@@ -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', () => {
+23 -1
View File
@@ -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'
+75
View File
@@ -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
}
+13
View File
@@ -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)
}