diff --git a/bun.lock b/bun.lock index 0b266421..fae6c5a2 100644 --- a/bun.lock +++ b/bun.lock @@ -52,10 +52,10 @@ "vite-plugin-pwa": "^1.2.0", }, "optionalDependencies": { - "@gbasin/agentboard-darwin-arm64": "0.4.2", - "@gbasin/agentboard-darwin-x64": "0.4.2", - "@gbasin/agentboard-linux-arm64": "0.4.2", - "@gbasin/agentboard-linux-x64": "0.4.2", + "@gbasin/agentboard-darwin-arm64": "0.4.3", + "@gbasin/agentboard-darwin-x64": "0.4.3", + "@gbasin/agentboard-linux-arm64": "0.4.3", + "@gbasin/agentboard-linux-x64": "0.4.3", }, }, }, diff --git a/package.json b/package.json index 5907dca8..51d2ee61 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gbasin/agentboard", - "version": "0.4.2", + "version": "0.4.3", "type": "module", "description": "Web GUI for tmux optimized for AI agent TUIs", "author": "gbasin", @@ -20,10 +20,10 @@ "bun": ">=1.3.14" }, "optionalDependencies": { - "@gbasin/agentboard-darwin-arm64": "0.4.2", - "@gbasin/agentboard-darwin-x64": "0.4.2", - "@gbasin/agentboard-linux-x64": "0.4.2", - "@gbasin/agentboard-linux-arm64": "0.4.2" + "@gbasin/agentboard-darwin-arm64": "0.4.3", + "@gbasin/agentboard-darwin-x64": "0.4.3", + "@gbasin/agentboard-linux-x64": "0.4.3", + "@gbasin/agentboard-linux-arm64": "0.4.3" }, "scripts": { "dev": "concurrently -k \"bun run dev:server\" \"bun run dev:client\"", diff --git a/src/client/__tests__/paste.test.ts b/src/client/__tests__/paste.test.ts new file mode 100644 index 00000000..2d6dfef7 --- /dev/null +++ b/src/client/__tests__/paste.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'bun:test' +import { bracketedPaste, imagePathInput, sanitizeImagePath } from '../utils/paste' + +describe('bracketedPaste', () => { + test('wraps text in bracketed-paste markers', () => { + expect(bracketedPaste('/tmp/x.png')).toBe('\x1b[200~/tmp/x.png\x1b[201~') + }) + + test('handles empty text', () => { + expect(bracketedPaste('')).toBe('\x1b[200~\x1b[201~') + }) +}) + +describe('imagePathInput', () => { + test('brackets the path for Claude so it attaches the image', () => { + expect(imagePathInput('/tmp/x.png', 'claude')).toBe('\x1b[200~/tmp/x.png\x1b[201~') + }) + + test('brackets the path for an unknown agent', () => { + expect(imagePathInput('/tmp/x.png', undefined)).toBe('\x1b[200~/tmp/x.png\x1b[201~') + }) + + test('sends the raw path for Codex (native clipboard paste)', () => { + expect(imagePathInput('/tmp/x.png', 'codex')).toBe('/tmp/x.png') + }) + + test('strips control characters before wrapping so a crafted path cannot break out', () => { + // A filename embedding the bracketed-paste end marker + escape sequence. + const malicious = '/tmp/a\x1b[201~\x1b[31mevil.png' + expect(imagePathInput(malicious, 'claude')).toBe('\x1b[200~/tmp/a[201~[31mevil.png\x1b[201~') + }) + + test('strips control characters for Codex raw paths too', () => { + expect(imagePathInput('/tmp/a\x07b.png', 'codex')).toBe('/tmp/ab.png') + }) +}) + +describe('sanitizeImagePath', () => { + test('removes C0 control characters and DEL', () => { + expect(sanitizeImagePath('/tmp/\x00\x1b\x07\x7fok.png')).toBe('/tmp/ok.png') + }) + + test('leaves ordinary paths (incl. spaces) untouched', () => { + expect(sanitizeImagePath('/Users/me/My Screenshot.png')).toBe('/Users/me/My Screenshot.png') + }) +}) diff --git a/src/client/__tests__/terminalControls.test.tsx b/src/client/__tests__/terminalControls.test.tsx index 62fc64ae..f20ec4f9 100644 --- a/src/client/__tests__/terminalControls.test.tsx +++ b/src/client/__tests__/terminalControls.test.tsx @@ -201,7 +201,7 @@ describe('TerminalControls', () => { expect(sent).toEqual(['manual']) }) - test('paste button uploads clipboard image and sends the stored path', async () => { + test('paste button uploads clipboard image and sends a bracketed path for Claude', async () => { const sent: string[] = [] const requests: Array<{ url: string; init?: RequestInit }> = [] @@ -223,6 +223,7 @@ describe('TerminalControls', () => { onSendKey={(key) => sent.push(key)} sessions={[{ id: 'session-1', name: 'alpha', status: 'working' }]} currentSessionId="session-1" + agentType="claude" onSelectSession={() => {}} /> ) @@ -238,6 +239,44 @@ describe('TerminalControls', () => { expect(requests).toHaveLength(1) expect(requests[0]?.url).toBe('/api/paste-image') + // Path wrapped in bracketed-paste markers so Claude attaches it ([Image #N]). + expect(sent).toEqual(['\x1b[200~/tmp/paste-test.png\x1b[201~']) + }) + + test('paste button sends the raw path for Codex (unchanged native behavior)', async () => { + const sent: string[] = [] + + globalAny.navigator = { + vibrate: () => true, + clipboard: clipboardWithImage(), + } as unknown as Navigator + + globalAny.fetch = (async () => + new Response(JSON.stringify({ path: '/tmp/paste-test.png' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) as unknown as typeof fetch + + const renderer = TestRenderer.create( + sent.push(key)} + sessions={[{ id: 'session-1', name: 'alpha', status: 'working' }]} + currentSessionId="session-1" + agentType="codex" + onSelectSession={() => {}} + /> + ) + + const pasteButton = findPasteButton(renderer) + if (!pasteButton) { + throw new Error('Expected paste button') + } + + await act(async () => { + await pasteButton.props.onClick() + }) + + // Codex attaches via its own clipboard path, so the raw path is sent as-is. expect(sent).toEqual(['/tmp/paste-test.png']) }) diff --git a/src/client/__tests__/useTerminal.test.tsx b/src/client/__tests__/useTerminal.test.tsx index 0f820d48..ca05c85a 100644 --- a/src/client/__tests__/useTerminal.test.tsx +++ b/src/client/__tests__/useTerminal.test.tsx @@ -1254,7 +1254,7 @@ describe('useTerminal', () => { globalThis.fetch = originalFetch }) - test('Ctrl+V on macOS sends empty bracket paste', async () => { + test('Ctrl+V on macOS with a clipboard image sends a bracketed image path', async () => { globalAny.navigator = { userAgent: 'Chrome', platform: 'MacIntel', @@ -1265,6 +1265,14 @@ describe('useTerminal', () => { }, } as unknown as Navigator + const originalFetch = globalThis.fetch + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (String(input) === '/api/clipboard-file-path') { + return { ok: true, json: async () => ({ path: '/tmp/shot.png', isImage: true }) } as Response + } + return originalFetch(input) + }) as typeof fetch + const sendCalls: Array> = [] const { container } = createContainerMock() @@ -1290,7 +1298,8 @@ describe('useTerminal', () => { const terminal = TerminalMock.instances[0] if (!terminal) throw new Error('Expected terminal instance') - // Ctrl+V on macOS (ctrlKey=true, metaKey=false) should trigger bracket paste + // Ctrl+V on macOS (ctrlKey=true, metaKey=false) is the dedicated image-paste + // shortcut. There's no browser paste event, so the server resolves the path. const result = terminal.emitKey({ key: 'v', type: 'keydown', @@ -1300,12 +1309,14 @@ describe('useTerminal', () => { // Should return false (swallowed because session is attached) expect(result).toBe(false) - // Should have sent bracket paste markers directly as terminal-input - // (not via terminal.paste('') which depends on bracketedPasteMode being on) + // Flush the async pasteboard lookup + await act(async () => { await new Promise((r) => setTimeout(r, 0)) }) + // Should deliver the resolved image path inside bracketed-paste markers so + // Claude attaches it natively ([Image #N]) in both renderers. expect(sendCalls).toContainEqual({ type: 'terminal-input', sessionId: 'session-1', - data: '\x1b[200~\x1b[201~', + data: '\x1b[200~/tmp/shot.png\x1b[201~', }) // Should NOT have called terminal.paste() — that path depends on bracketedPasteMode expect(terminal.pasteCalls).toEqual([]) @@ -1313,6 +1324,73 @@ describe('useTerminal', () => { act(() => { renderer.unmount() }) + globalThis.fetch = originalFetch + }) + + test('Ctrl+V on macOS with no clipboard image falls back to empty bracket paste', async () => { + globalAny.navigator = { + userAgent: 'Chrome', + platform: 'MacIntel', + maxTouchPoints: 0, + clipboard: { + writeText: () => Promise.resolve(), + readText: () => Promise.resolve(''), + }, + } as unknown as Navigator + + const originalFetch = globalThis.fetch + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (String(input) === '/api/clipboard-file-path') { + return { ok: true, json: async () => ({ path: null }) } as Response + } + return originalFetch(input) + }) as typeof fetch + + const sendCalls: Array> = [] + const { container } = createContainerMock() + + let renderer!: TestRenderer.ReactTestRenderer + + await act(async () => { + renderer = TestRenderer.create( + sendCalls.push(message)} + subscribe={() => () => {}} + theme={{ background: '#000' }} + fontSize={12} + />, + { + createNodeMock: () => container, + }, + ) + await Promise.resolve() + }) + + const terminal = TerminalMock.instances[0] + if (!terminal) throw new Error('Expected terminal instance') + + const result = terminal.emitKey({ + key: 'v', + type: 'keydown', + ctrlKey: true, + metaKey: false, + }) + + expect(result).toBe(false) + await act(async () => { await new Promise((r) => setTimeout(r, 0)) }) + expect(sendCalls).toContainEqual({ + type: 'terminal-input', + sessionId: 'session-1', + data: '\x1b[200~\x1b[201~', + }) + expect(terminal.pasteCalls).toEqual([]) + + act(() => { + renderer.unmount() + }) + globalThis.fetch = originalFetch }) test('Ctrl+V on macOS sends literal Ctrl+V for Codex image paste', async () => { @@ -1360,6 +1438,9 @@ describe('useTerminal', () => { }) expect(result).toBe(false) + // Codex short-circuits to a literal Ctrl+V byte (its own native paste path), + // resolved on a microtask — flush before asserting. + await act(async () => { await new Promise((r) => setTimeout(r, 0)) }) expect(sendCalls).toContainEqual({ type: 'terminal-input', sessionId: 'session-1', @@ -1383,6 +1464,14 @@ describe('useTerminal', () => { }, } as unknown as Navigator + const originalFetch = globalThis.fetch + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (String(input) === '/api/clipboard-file-path') { + return { ok: true, json: async () => ({ path: null }) } as Response + } + return originalFetch(input) + }) as typeof fetch + const sendCalls: Array> = [] const listeners: Array<(message: ServerMessage) => void> = [] const { container } = createContainerMock() @@ -1431,11 +1520,13 @@ describe('useTerminal', () => { metaKey: false, }) - // Should have sent tmux-cancel-copy-mode BEFORE the bracket paste + // cancel-copy-mode is sent synchronously, before the async paste resolves. expect(sendCalls[0]).toEqual({ type: 'tmux-cancel-copy-mode', sessionId: 'session-1', }) + // Flush the async pasteboard lookup; with no image it falls back to empty bracket. + await act(async () => { await new Promise((r) => setTimeout(r, 0)) }) expect(sendCalls[1]).toEqual({ type: 'terminal-input', sessionId: 'session-1', @@ -1446,6 +1537,7 @@ describe('useTerminal', () => { act(() => { renderer.unmount() }) + globalThis.fetch = originalFetch }) test('appMouse=true scroll-up forwards wheel without disabling mouse tracking or entering copy-mode', async () => { @@ -2247,7 +2339,7 @@ describe('useTerminal', () => { globalThis.fetch = originalFetch }) - test('Cmd+V with macOS clipboard image path sends empty bracket paste', async () => { + test('Cmd+V with macOS clipboard image path sends a bracketed image path', async () => { jest.useFakeTimers() globalAny.navigator = { userAgent: 'Chrome', @@ -2259,15 +2351,13 @@ describe('useTerminal', () => { }, } as unknown as Navigator + const imagePath = '/Users/test/Documents/Screenshots/CleanShot 2026-06-13 at 10.41.52@2x.png' const originalFetch = globalThis.fetch globalThis.fetch = (async (input: RequestInfo | URL) => { if (String(input) === '/api/clipboard-file-path') { return { ok: true, - json: async () => ({ - path: '/Users/test/Documents/Screenshots/CleanShot 2026-06-13 at 10.41.52@2x.png', - isImage: true, - }), + json: async () => ({ path: imagePath, isImage: true }), } as Response } return originalFetch(input) @@ -2309,10 +2399,12 @@ describe('useTerminal', () => { jest.advanceTimersByTime(100) }) + // The resolved image path is delivered as a bracketed paste so Claude + // attaches it natively, instead of the old empty-bracket clipboard signal. expect(sendCalls).toContainEqual({ type: 'terminal-input', sessionId: 'session-1', - data: '\x1b[200~\x1b[201~', + data: `\x1b[200~${imagePath}\x1b[201~`, }) expect(terminal.pasteCalls).toEqual([]) @@ -2554,7 +2646,7 @@ describe('useTerminal', () => { globalThis.fetch = originalFetch }) - test('Cmd+V with image clipboard metadata sends empty bracket paste', async () => { + test('Cmd+V with image clipboard metadata (no readable blob) resolves a pasteboard path', async () => { jest.useFakeTimers() globalAny.navigator = { userAgent: 'Chrome', @@ -2566,12 +2658,13 @@ describe('useTerminal', () => { }, } as unknown as Navigator + const imagePath = '/tmp/agentboard-paste-abc.png' const originalFetch = globalThis.fetch const fetchCalls: string[] = [] globalThis.fetch = (async (input: RequestInfo | URL) => { fetchCalls.push(String(input)) if (String(input) === '/api/clipboard-file-path') { - return { ok: true, json: async () => ({ path: '/Users/test/file.txt' }) } as Response + return { ok: true, json: async () => ({ path: imagePath, isImage: true }) } as Response } return originalFetch(input) }) as typeof fetch @@ -2601,6 +2694,8 @@ describe('useTerminal', () => { terminal.emitKey({ key: 'v', type: 'keydown', metaKey: true, ctrlKey: false }) + // Image present only as metadata (no getAsFile) — the blob can't be read in + // the browser, so we fall back to a server-side pasteboard lookup. dispatchEvent('paste', { type: 'paste', preventDefault: () => {}, @@ -2619,9 +2714,88 @@ describe('useTerminal', () => { expect(sendCalls).toContainEqual({ type: 'terminal-input', sessionId: 'session-1', - data: '\x1b[200~\x1b[201~', + data: `\x1b[200~${imagePath}\x1b[201~`, + }) + expect(fetchCalls).toContain('/api/clipboard-file-path') + expect(terminal.pasteCalls).toEqual([]) + + act(() => { renderer.unmount() }) + globalThis.fetch = originalFetch + }) + + test('Cmd+V with a readable image blob uploads it and sends a bracketed path', async () => { + jest.useFakeTimers() + globalAny.navigator = { + userAgent: 'Chrome', + platform: 'MacIntel', + maxTouchPoints: 0, + clipboard: { + writeText: () => Promise.resolve(), + readText: () => Promise.resolve(''), + }, + } as unknown as Navigator + + const uploadedPath = '/tmp/paste-123-abcdef.png' + const originalFetch = globalThis.fetch + const fetchCalls: string[] = [] + globalThis.fetch = (async (input: RequestInfo | URL) => { + fetchCalls.push(String(input)) + if (String(input) === '/api/paste-image') { + return { ok: true, json: async () => ({ path: uploadedPath }) } as Response + } + return originalFetch(input) + }) as typeof fetch + + const sendCalls: Array> = [] + const { container, dispatchEvent } = createContainerMock() + + let renderer!: TestRenderer.ReactTestRenderer + + await act(async () => { + renderer = TestRenderer.create( + sendCalls.push(message)} + subscribe={() => () => {}} + theme={{ background: '#000' }} + fontSize={12} + />, + { createNodeMock: () => container }, + ) + await Promise.resolve() }) + + const terminal = TerminalMock.instances[0] + if (!terminal) throw new Error('Expected terminal instance') + + terminal.emitKey({ key: 'v', type: 'keydown', metaKey: true, ctrlKey: false }) + + // Real screenshot paste: the clipboard exposes a readable image File. + const blob = new Blob([new Uint8Array([1, 2, 3, 4])], { type: 'image/png' }) + dispatchEvent('paste', { + type: 'paste', + preventDefault: () => {}, + stopPropagation: () => {}, + clipboardData: { + files: [blob], + items: [{ kind: 'file', type: 'image/png', getAsFile: () => blob }], + getData: () => '', + }, + }) + + await act(async () => { + jest.advanceTimersByTime(100) + }) + + // The blob is uploaded and its stored path delivered as a bracketed paste. + expect(fetchCalls).toContain('/api/paste-image') expect(fetchCalls).not.toContain('/api/clipboard-file-path') + expect(sendCalls).toContainEqual({ + type: 'terminal-input', + sessionId: 'session-1', + data: `\x1b[200~${uploadedPath}\x1b[201~`, + }) expect(terminal.pasteCalls).toEqual([]) act(() => { renderer.unmount() }) diff --git a/src/client/components/Terminal.tsx b/src/client/components/Terminal.tsx index 17d3cb53..dfbb92cd 100644 --- a/src/client/components/Terminal.tsx +++ b/src/client/components/Terminal.tsx @@ -1458,6 +1458,7 @@ export default function Terminal({ disabled={connectionStatus !== 'connected' || isReadOnly} sessions={sessions.map(s => ({ id: s.id, name: s.name, status: s.status }))} currentSessionId={session.id} + agentType={session.agentType} onSelectSession={onSelectSession} hideSessionSwitcher onRefocus={handleRefocus} diff --git a/src/client/components/TerminalControls.tsx b/src/client/components/TerminalControls.tsx index 255106c0..38f8cf12 100644 --- a/src/client/components/TerminalControls.tsx +++ b/src/client/components/TerminalControls.tsx @@ -6,11 +6,12 @@ import { useState, useRef, useEffect } from 'react' import type { TouchEvent as ReactTouchEvent } from 'react' -import type { Session } from '@shared/types' +import type { AgentType, Session } from '@shared/types' import { CornerDownLeftIcon } from '@untitledui-icons/react/line' import DPad from './DPad' import NumPad from './NumPad' import { isIOSDevice } from '../utils/device' +import { imagePathInput } from '../utils/paste' interface SessionInfo { id: string @@ -23,6 +24,8 @@ interface TerminalControlsProps { disabled?: boolean sessions: SessionInfo[] currentSessionId: string | null + /** Agent type of the attached session — controls image-paste delivery. */ + agentType?: AgentType onSelectSession: (sessionId: string) => void hideSessionSwitcher?: boolean onRefocus?: () => void @@ -149,6 +152,7 @@ export default function TerminalControls({ disabled = false, sessions, currentSessionId, + agentType, onSelectSession, hideSessionSwitcher = false, onRefocus, @@ -241,7 +245,7 @@ export default function TerminalControls({ `paste.${item.type.split('/')[1] || 'png'}` ) if ('path' in result) { - onSendKey(result.path) + onSendKey(imagePathInput(result.path, agentType)) setShowPasteInput(false) setPasteValue('') onRefocus?.() @@ -258,7 +262,7 @@ export default function TerminalControls({ zone.addEventListener('paste', handlePaste) return () => zone.removeEventListener('paste', handlePaste) - }, [showPasteInput, onSendKey, onRefocus]) + }, [showPasteInput, onSendKey, onRefocus, agentType]) const handlePress = (key: string) => { if (disabled) return @@ -313,8 +317,9 @@ export default function TerminalControls({ `paste.${imageType.split('/')[1] || 'png'}` ) if ('path' in result) { - // Send file path - Claude Code can reference images by path - onSendKey(result.path) + // Deliver the uploaded path so the agent attaches the image: + // bracketed paste for Claude ([Image #N]), raw path for Codex. + onSendKey(imagePathInput(result.path, agentType)) if (wasKeyboardVisible) { onRefocus?.() } diff --git a/src/client/hooks/useTerminal.ts b/src/client/hooks/useTerminal.ts index ad074769..e5a0930a 100644 --- a/src/client/hooks/useTerminal.ts +++ b/src/client/hooks/useTerminal.ts @@ -11,6 +11,7 @@ import type { AgentType, ClipboardOfferSource, SendClientMessage, ServerMessageW import { clientLog } from '../utils/clientLog' import type { ConnectionStatus } from '../stores/sessionStore' import { copyText } from '../utils/copyText' +import { bracketedPaste, sanitizeImagePath } from '../utils/paste' // Module-level snapshot cache: sessionId → serialized terminal content. // Survives component remounts and avoids stale-closure issues in effects. @@ -74,8 +75,66 @@ const CTRL_V = '\x16' const ENABLE_MOUSE_TRACKING = '\x1b[?1000h\x1b[?1002h\x1b[?1006h' const DISABLE_MOUSE_TRACKING = '\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l' -function getImagePasteSignal(agentType: AgentType | undefined): string { - return agentType === 'codex' ? CTRL_V : BRACKET_PASTE_EMPTY +/** + * Upload an image blob to the server and return the stored file path. + * Returns null on any failure. Bun derives the multipart File.type from the + * filename extension (not the declared blob type), so the filename carries the + * real extension. + */ +async function uploadImageBlob(blob: Blob): Promise { + try { + const ext = (blob.type.split('/')[1] || 'png').toLowerCase() + const form = new FormData() + form.append('image', blob, `paste.${ext}`) + const res = await fetch('/api/paste-image', { method: 'POST', body: form }) + if (!res.ok) return null + const data = (await res.json()) as { path?: unknown } + return typeof data.path === 'string' ? data.path : null + } catch { + return null + } +} + +/** + * Ask the server to read the macOS pasteboard and return a file path for an + * image (a Finder file URL, or a temp file the server wrote from raw clipboard + * image data). Returns null when the clipboard holds no image. + */ +async function fetchClipboardImagePath(): Promise { + try { + const res = await fetch('/api/clipboard-file-path') + if (!res.ok) return null + const data = (await res.json()) as { path?: unknown; isImage?: unknown } + if (data.isImage === true && typeof data.path === 'string') return data.path + return null + } catch { + return null + } +} + +/** + * Resolve the terminal-input payload that makes the attached agent attach a + * pasted image. + * + * Claude Code attaches an image when it receives the image's file PATH inside a + * bracketed paste — not as raw-typed text, and not via an empty bracket paste. + * The old empty-bracket signal relied on Claude reading the macOS system + * clipboard itself, which its fullscreen/no-flicker renderer no longer does, so + * we now deliver an explicit path: upload the clipboard blob when we have it, + * otherwise ask the server to resolve a pasteboard path. Codex keeps its + * literal Ctrl+V signal (its own native image-paste path). + */ +async function resolveImagePasteData( + agentType: AgentType | undefined, + source: { blob?: Blob | null; path?: string | null } +): Promise { + if (agentType === 'codex') return CTRL_V + let path = source.path ?? null + if (!path && source.blob) path = await uploadImageBlob(source.blob) + if (!path) path = await fetchClipboardImagePath() + // Fall back to the empty-bracket signal so the classic renderer's native + // clipboard read still works when no path could be resolved. + return path ? bracketedPaste(sanitizeImagePath(path)) : BRACKET_PASTE_EMPTY } const MAC_SHARED_PASTEBOARD_PATH_PATTERN = @@ -88,6 +147,7 @@ function isMacSharedPasteboardPathText(text: string): boolean { interface PastePayload { text: string hasImage: boolean + imageBlob: Blob | null } interface PendingClipboardOffer { @@ -113,6 +173,30 @@ function clipboardHasImage(clipboardData: DataTransfer | null | undefined): bool return false } +/** + * Extract the first image blob from clipboard data, if one is present and + * directly readable. Returns null when the image can't be pulled as a File + * (e.g. metadata-only items), in which case callers fall back to a + * server-side pasteboard read. + */ +function getClipboardImageBlob( + clipboardData: DataTransfer | null | undefined +): Blob | null { + if (!clipboardData) return null + + for (const file of Array.from(clipboardData.files ?? [])) { + if (file?.type?.startsWith('image/')) return file + } + + for (const item of Array.from(clipboardData.items ?? [])) { + if (item?.kind === 'file' && item.type?.startsWith('image/')) { + const file = item.getAsFile?.() + if (file) return file + } + } + return null +} + /** * Custom clipboard provider that prevents empty writes (matching Ghostty's behavior). * OSC 52 with empty base64 data clears clipboard in reference xterm, but this can @@ -661,6 +745,16 @@ export function useTerminal({ const pasteModifier = getIsMac() ? 'metaKey' : 'ctrlKey' as const + // Paste delivery often resolves after async work (clipboard read, image + // upload). Only forward input if the session captured when the paste began + // is still the attached one, so a mid-paste session switch can't deliver to + // the wrong (or a no-longer-viewed) session. + const sendInputIfStillAttached = (expected: string, data: string | null) => { + if (data && attachedSessionRef.current === expected) { + sendMessageRef.current({ type: 'terminal-input', sessionId: expected, data }) + } + } + terminal.attachCustomKeyEventHandler((event) => { // Cmd/Ctrl+C: copy selection (only non-whitespace to avoid clearing images from clipboard) if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c') { @@ -673,25 +767,26 @@ export function useTerminal({ } } - // Ctrl+V on macOS desktop: route image paste through the agent-specific - // signal. Claude Code reads the macOS clipboard after an empty bracket - // paste; Codex handles a literal Ctrl+V byte. + // Ctrl+V on macOS desktop: dedicated image-paste shortcut. There's no + // browser paste event (so no clipboard blob), so we ask the server to + // resolve a pasteboard image path and deliver it as a bracketed paste for + // Claude to attach natively; Codex gets a literal Ctrl+V byte. // Ctrl+V doesn't trigger a browser paste event, so there's no double-paste risk. // Excluded on iOS: no Finder/osascript, and Ctrl+V with hardware keyboard // should not trigger desktop image paste behavior. if (getIsMac() && !isiOS && event.ctrlKey && !event.metaKey && event.key.toLowerCase() === 'v' && event.type === 'keydown') { - if (attachedSessionRef.current) { + const attached = attachedSessionRef.current + if (attached) { if (inTmuxCopyModeRef.current) { - sendMessageRef.current({ type: 'tmux-cancel-copy-mode', sessionId: attachedSessionRef.current }) + sendMessageRef.current({ type: 'tmux-cancel-copy-mode', sessionId: attached }) setTmuxCopyMode(false) } - sendMessageRef.current({ - type: 'terminal-input', - sessionId: attachedSessionRef.current, - data: getImagePasteSignal(agentTypeRef.current), - }) + void (async () => { + const data = await resolveImagePasteData(agentTypeRef.current, {}) + sendInputIfStillAttached(attached, data) + })() } - return !attachedSessionRef.current // Only swallow when attached + return !attached // Only swallow when attached } // Cmd+V on macOS / Ctrl+V on other platforms: intercept paste to handle @@ -737,7 +832,10 @@ export function useTerminal({ } if (payload?.hasImage && getIsMac() && !isiOS) { - sendMessageRef.current({ type: 'terminal-input', sessionId: attached, data: getImagePasteSignal(agentTypeRef.current) }) + // Upload the clipboard image and deliver its path as a bracketed + // paste so Claude attaches it natively (works in both renderers). + const data = await resolveImagePasteData(agentTypeRef.current, { blob: payload.imageBlob }) + sendInputIfStillAttached(attached, data) return } @@ -750,12 +848,14 @@ export function useTerminal({ const { path, isImage } = (await res.json()) as { path: string | null; isImage?: boolean } if (path) { if (isImage) { - sendMessageRef.current({ type: 'terminal-input', sessionId: attached, data: getImagePasteSignal(agentTypeRef.current) }) + // Deliver the resolved image path as a bracketed paste. + const data = await resolveImagePasteData(agentTypeRef.current, { path }) + sendInputIfStillAttached(attached, data) return } // Send as raw input (no bracket paste) so Claude Code // doesn't detect a paste and read the system clipboard - sendMessageRef.current({ type: 'terminal-input', sessionId: attached, data: path }) + sendInputIfStillAttached(attached, path) return } } @@ -769,9 +869,11 @@ export function useTerminal({ // Shared Mac clipboard image pastes can expose only a protected // useractivityd path to the browser. Sending that path to an agent - // is useless; ask the attached CLI to run its native image-paste path. + // is useless; ask the server to resolve a real pasteboard image path + // (falling back to the empty-bracket signal) so the CLI can attach it. if (getIsMac() && !isiOS) { - sendMessageRef.current({ type: 'terminal-input', sessionId: attached, data: getImagePasteSignal(agentTypeRef.current) }) + const data = await resolveImagePasteData(agentTypeRef.current, {}) + sendInputIfStillAttached(attached, data) } } finally { pasteResolver = null @@ -801,10 +903,11 @@ export function useTerminal({ e.preventDefault() e.stopPropagation() const text = e.clipboardData?.getData('text/plain') ?? '' - const hasImage = clipboardHasImage(e.clipboardData) + const imageBlob = getClipboardImageBlob(e.clipboardData) + const hasImage = imageBlob !== null || clipboardHasImage(e.clipboardData) const resolver = pasteResolver pasteResolver = null - resolver({ text, hasImage }) + resolver({ text, hasImage, imageBlob }) } container.addEventListener('paste', handlePaste, { capture: true }) diff --git a/src/client/utils/paste.ts b/src/client/utils/paste.ts new file mode 100644 index 00000000..e5448539 --- /dev/null +++ b/src/client/utils/paste.ts @@ -0,0 +1,39 @@ +import type { AgentType } from '@shared/types' + +/** + * Wrap text in bracketed-paste markers so the attached agent treats it as a + * paste rather than typed input. Claude Code attaches an image when it receives + * the image's file path inside a bracketed paste (a raw-typed path is just + * inserted as literal text). + */ +export function bracketedPaste(text: string): string { + return `\x1b[200~${text}\x1b[201~` +} + +/** + * Strip C0 control characters and DEL from a file path before it's delivered to + * the terminal. A legitimate image path never contains these, but a crafted + * filename could embed ESC / the bracketed-paste end marker and break out of + * the paste sequence to inject terminal control codes. + */ +export function sanitizeImagePath(path: string): string { + // eslint-disable-next-line no-control-regex + return path.replace(/[\x00-\x1f\x7f]/g, '') +} + +/** + * Build the terminal-input payload that delivers an uploaded image's file path + * to the attached agent so it attaches the image. + * + * Claude (and unknown agents) get the path wrapped in a bracketed paste, which + * produces a native [Image #N] attachment. Codex is left untouched — it + * attaches images via its own clipboard path, not a typed file path, so we send + * the raw path exactly as before. + */ +export function imagePathInput( + path: string, + agentType: AgentType | undefined +): string { + const clean = sanitizeImagePath(path) + return agentType === 'codex' ? clean : bracketedPaste(clean) +}