diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json index 578bc1b2d63..c8502d4fc3e 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json @@ -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" }, diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json index 2e9aeec1063..a43ff95f0e9 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json @@ -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" }, diff --git a/mobile/rpc-foundation/pilot-scenarios.json b/mobile/rpc-foundation/pilot-scenarios.json index c33ee08ee28..b634a264120 100644 --- a/mobile/rpc-foundation/pilot-scenarios.json +++ b/mobile/rpc-foundation/pilot-scenarios.json @@ -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": { diff --git a/mobile/src/session/mobile-clipboard-image.test.ts b/mobile/src/session/mobile-clipboard-image.test.ts index 6a9876de9a2..f60e3943750 100644 --- a/mobile/src/session/mobile-clipboard-image.test.ts +++ b/mobile/src/session/mobile-clipboard-image.test.ts @@ -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', () => { diff --git a/mobile/src/session/mobile-clipboard-image.ts b/mobile/src/session/mobile-clipboard-image.ts index 486e5a9f7bd..8ba9771efb1 100644 --- a/mobile/src/session/mobile-clipboard-image.ts +++ b/mobile/src/session/mobile-clipboard-image.ts @@ -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~` } diff --git a/mobile/src/session/mobile-image-attachment.test.ts b/mobile/src/session/mobile-image-attachment.test.ts index bce554392df..6c8e6096bb5 100644 --- a/mobile/src/session/mobile-image-attachment.test.ts +++ b/mobile/src/session/mobile-image-attachment.test.ts @@ -25,6 +25,40 @@ function clientWithResponses(responses: RpcResponse[]): Pick { + 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([ { diff --git a/mobile/src/session/mobile-image-attachment.ts b/mobile/src/session/mobile-image-attachment.ts index 6da9d404aad..a28c0d86db0 100644 --- a/mobile/src/session/mobile-image-attachment.ts +++ b/mobile/src/session/mobile-image-attachment.ts @@ -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))) { diff --git a/mobile/src/session/mobile-image-live-input-order.test.tsx b/mobile/src/session/mobile-image-live-input-order.test.tsx new file mode 100644 index 00000000000..888408cf4f1 --- /dev/null +++ b/mobile/src/session/mobile-image-live-input-order.test.tsx @@ -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 = (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((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((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('term-1') + const activeSessionTabTypeRef = ref('terminal') + const liveInputRef = ref(null) + const sendLiveTerminalInputRef = ref(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 + let paste!: ReturnType + let beforeTerminalSend!: (target: string) => Promise + 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[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 } + ]) +}) diff --git a/mobile/src/session/mobile-image-paste-agent.test.ts b/mobile/src/session/mobile-image-paste-agent.test.ts new file mode 100644 index 00000000000..e730399c2df --- /dev/null +++ b/mobile/src/session/mobile-image-paste-agent.test.ts @@ -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> = {}) { + 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') + }) +}) diff --git a/mobile/src/session/mobile-image-paste-agent.ts b/mobile/src/session/mobile-image-paste-agent.ts new file mode 100644 index 00000000000..47cd1c2d3d2 --- /dev/null +++ b/mobile/src/session/mobile-image-paste-agent.ts @@ -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 +} diff --git a/mobile/src/session/use-mobile-session-attachments.ts b/mobile/src/session/use-mobile-session-attachments.ts index a1586dd111a..35078cacf6e 100644 --- a/mobile/src/session/use-mobile-session-attachments.ts +++ b/mobile/src/session/use-mobile-session-attachments.ts @@ -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, diff --git a/mobile/src/session/use-mobile-terminal-paste.test.ts b/mobile/src/session/use-mobile-terminal-paste.test.ts index 814b40574bb..c9f58f50841 100644 --- a/mobile/src/session/use-mobile-terminal-paste.test.ts +++ b/mobile/src/session/use-mobile-terminal-paste.test.ts @@ -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((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((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() + } + ) }) diff --git a/mobile/src/session/use-mobile-terminal-paste.ts b/mobile/src/session/use-mobile-terminal-paste.ts index 041faa639f7..8a72cf4b94d 100644 --- a/mobile/src/session/use-mobile-terminal-paste.ts +++ b/mobile/src/session/use-mobile-terminal-paste.ts @@ -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 ) } diff --git a/src/renderer/src/components/terminal-pane/pane-helpers.ts b/src/renderer/src/components/terminal-pane/pane-helpers.ts index 9104b091e59..9ae1ecbf161 100644 --- a/src/renderer/src/components/terminal-pane/pane-helpers.ts +++ b/src/renderer/src/components/terminal-pane/pane-helpers.ts @@ -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' diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-image-path.ts b/src/renderer/src/components/terminal-pane/terminal-drop-image-path.ts index 6f4cc98f031..c34fc01b002 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-image-path.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-image-path.ts @@ -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' diff --git a/src/shared/image-path-paste.ts b/src/shared/image-path-paste.ts new file mode 100644 index 00000000000..85026408766 --- /dev/null +++ b/src/shared/image-path-paste.ts @@ -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 +} diff --git a/src/shared/shell-path-quoting.ts b/src/shared/shell-path-quoting.ts new file mode 100644 index 00000000000..e1b2c3d0b35 --- /dev/null +++ b/src/shared/shell-path-quoting.ts @@ -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) +}