diff --git a/PRIVACY.md b/PRIVACY.md index c6a2f999..8997e914 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -75,7 +75,7 @@ Across all of the above, no traffic is routed through servers operated by the Pa A few features hand short pieces of data to your operating system itself: -- **Clipboard pasting and drag-drop** — when you paste into or drop content onto an agent prompt, Parallel Code reads the OS clipboard or the drop payload. If it contains a file reference (`public.file-url`, `text/uri-list`, or GNOME's `x-special/gnome-copied-files`), the file path is passed to the agent. If it contains an image, the image bytes are written to your OS temp directory so the agent can reference the image as a file path: clipboard pastes overwrite a single file at `$TMPDIR/parallel-code-clipboard.png`, and drops are written to `$TMPDIR/parallel-code-drop--[-]` — the original drop filename, if any, is sanitised and appended. These temp files are not deleted by Parallel Code; they remain in your temp directory until your OS cleans it (typically on reboot or via periodic cleanup). Plain text is inserted into the prompt. As with any prompt, once you submit it (or the app detects it as prompt input from the terminal), it can be stored locally as task metadata (the task's `lastPrompt`) in `state.json`. +- **Clipboard pasting and drag-drop** — when you paste into or drop content onto an agent prompt, Parallel Code reads the OS clipboard or the drop payload. If it contains a file reference (`public.file-url`, `text/uri-list`, or GNOME's `x-special/gnome-copied-files`), the file path is passed to the agent. If it contains an image, the image bytes are written to a unique `$TMPDIR/parallel-code-drop--[-]` file so the agent can reference the image as a file path; clipboard images use `clipboard.png` as the original filename. These temp files are not deleted by Parallel Code, so repeated clipboard images and drops accumulate until your OS cleans its temp directory (typically on reboot or via periodic cleanup). Plain text is inserted into the prompt. As with any prompt, once you submit it (or the app detects it as prompt input from the terminal), it can be stored locally as task metadata (the task's `lastPrompt`) in `state.json`. - **Clipboard writes** — when you click "Copy" in certain UI controls (for example the Connect Phone dialog), Parallel Code writes to the OS clipboard. The Remote Access URL it copies contains the **session bearer token** as a query parameter, so anything that reads or syncs your clipboard (clipboard managers, screen-sharing tools, cross-device clipboard sync) will see that token until you copy something else or restart Remote Access. Other clipboard writes (terminal selection, theme prompts, task steps) carry only the content you asked to copy. - **Native notifications** — when a task completes, needs attention, or when GitHub PR checks succeed or fail, Parallel Code uses the OS notification API. The notification's title and body (typically the task name and a short status; for failed PR checks, the failed check names) are visible to the operating system and any surfaces that mirror notifications (Notification Center, etc.). - **Microphone entitlement (macOS)** — the packaged macOS app declares `NSMicrophoneUsageDescription` and the corresponding hardened-runtime entitlement, so the OS will permit microphone access if a feature requests it. The current build contains no active microphone capture code; granting the permission has no effect until a microphone-using feature ships. Note that the renderer permission handler in `electron/main.ts` auto-approves audio media requests — so if microphone-using code is added later, the only consent gate will be the OS-level microphone prompt. @@ -101,7 +101,7 @@ Inside the git repositories you point the app at, Parallel Code may also write: Other places Parallel Code can write state: - `~/.parallel-code/agent-auth//` — only if you enable "Share agent auth across Linux containers" (see Docker task isolation); accumulates agent credentials across sessions. -- OS temp directory (`$TMPDIR`) — `parallel-code-clipboard.png` and the `parallel-code-drop-*` files from clipboard/drop image handling (not deleted by Parallel Code), plus the host-mode coordinator and sub-task MCP configs and `.parallel-code-atomic-.tmp` files described under Sub-task coordinator above. +- OS temp directory (`$TMPDIR`) — `parallel-code-drop-*` files from clipboard/drop image handling (not deleted by Parallel Code), plus the host-mode coordinator and sub-task MCP configs and `.parallel-code-atomic-.tmp` files described under Sub-task coordinator above. Persistence the app itself does not control but that mirrors content the app produced: diff --git a/electron/ipc/ask-code-minimax.test.ts b/electron/ipc/ask-code-minimax.test.ts index 8a73d51a..ff7394e5 100644 --- a/electron/ipc/ask-code-minimax.test.ts +++ b/electron/ipc/ask-code-minimax.test.ts @@ -1,4 +1,8 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { execFileSync } from 'child_process'; // Mock fetch globally const mockFetch = vi.fn(); @@ -7,7 +11,6 @@ vi.stubGlobal('fetch', mockFetch); import { askAboutCodeMinimax, cancelAskAboutCodeMinimax, - MINIMAX_MODEL, setMinimaxApiKey, } from './ask-code-minimax.js'; @@ -180,7 +183,7 @@ describe('askAboutCodeMinimax', () => { const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) as { model: string; }; - expect(body.model).toBe(MINIMAX_MODEL); + expect(body.model).toBe('MiniMax-M2.7'); }); it('uses temperature in MiniMax allowed range (0, 1]', async () => { @@ -261,6 +264,202 @@ describe('askAboutCodeMinimax', () => { }); }); +describe('minimax image input', () => { + const pngPath = path.join(os.tmpdir(), 'parallel-code-ask-code-test.png'); + const jpegWithPngExtensionPath = path.join(os.tmpdir(), 'parallel-code-ask-code-jpeg-test.png'); + const oversizedPath = path.join(os.tmpdir(), 'parallel-code-ask-code-oversized.png'); + const fifoPath = path.join(os.tmpdir(), 'parallel-code-ask-code-fifo.png'); + const pngBytes = Buffer.from('89504e470d0a1a0a', 'hex'); + const jpegBytes = Buffer.from('ffd8ffe000104a464946', 'hex'); + + beforeAll(() => { + fs.writeFileSync(pngPath, pngBytes); + fs.writeFileSync(jpegWithPngExtensionPath, jpegBytes); + fs.writeFileSync(oversizedPath, pngBytes); + fs.truncateSync(oversizedPath, 10 * 1024 * 1024 + 1); + // A FIFO stats as size 0, so it slips past the size caps; opening it for + // read blocks until a writer appears, which nothing here provides. + fs.rmSync(fifoPath, { force: true }); + execFileSync('mkfifo', [fifoPath]); + }); + + afterAll(() => { + fs.rmSync(pngPath, { force: true }); + fs.rmSync(jpegWithPngExtensionPath, { force: true }); + fs.rmSync(oversizedPath, { force: true }); + fs.rmSync(fifoPath, { force: true }); + }); + + beforeEach(() => { + vi.clearAllMocks(); + setMinimaxApiKey('test-key'); + }); + + function requestBody() { + return JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) as { + model: string; + messages: Array<{ + role: string; + content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>; + }>; + }; + } + + it('keeps the user content a plain string when no image is attached', async () => { + const { win, messages } = makeMockWin(); + + mockFetch.mockResolvedValueOnce(makeStreamResponse('data: [DONE]\n\n')); + + askAboutCodeMinimax(win, { + requestId: 'img-none', + channelId: 'ch-img-none', + prompt: 'Explain this', + }); + + await waitForDone(messages); + + const body = requestBody(); + expect(body.model).toBe('MiniMax-M2.7'); + expect(body.messages.find((m) => m.role === 'user')?.content).toBe('Explain this'); + }); + + it('sends attached images as content parts to an image-capable model', async () => { + const { win, messages } = makeMockWin(); + + mockFetch.mockResolvedValueOnce(makeStreamResponse('data: [DONE]\n\n')); + + askAboutCodeMinimax(win, { + requestId: 'img-one', + channelId: 'ch-img-one', + prompt: 'What does this screenshot show?', + imagePaths: [pngPath], + }); + + await waitForDone(messages); + + const body = requestBody(); + expect(body.model).toBe('MiniMax-M3'); + + const userContent = body.messages.find((m) => m.role === 'user')?.content; + expect(Array.isArray(userContent)).toBe(true); + const parts = userContent as Array<{ + type: string; + text?: string; + image_url?: { url: string }; + }>; + expect(parts[0]).toEqual({ type: 'text', text: 'What does this screenshot show?' }); + expect(parts[1].type).toBe('image_url'); + expect(parts[1].image_url?.url).toBe(`data:image/png;base64,${pngBytes.toString('base64')}`); + + // The system message stays a plain string + expect(typeof body.messages.find((m) => m.role === 'system')?.content).toBe('string'); + }); + + it('uses the file signature for the image MIME type', async () => { + const { win, messages } = makeMockWin(); + + mockFetch.mockResolvedValueOnce(makeStreamResponse('data: [DONE]\n\n')); + + askAboutCodeMinimax(win, { + requestId: 'img-signature', + channelId: 'ch-img-signature', + prompt: 'Inspect this image', + imagePaths: [jpegWithPngExtensionPath], + }); + + await waitForDone(messages); + + const content = requestBody().messages.find((message) => message.role === 'user')?.content; + const parts = content as Array<{ image_url?: { url: string } }>; + expect(parts[1].image_url?.url).toBe(`data:image/jpeg;base64,${jpegBytes.toString('base64')}`); + }); + + it('rejects image types the chat API cannot accept', () => { + const { win } = makeMockWin(); + + expect(() => + askAboutCodeMinimax(win, { + requestId: 'img-bad-type', + channelId: 'ch-img-bad-type', + prompt: 'Test', + imagePaths: [path.join(os.tmpdir(), 'notes.txt')], + }), + ).toThrow(/Unsupported image type/); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('rejects more images than a single question allows', () => { + const { win } = makeMockWin(); + + expect(() => + askAboutCodeMinimax(win, { + requestId: 'img-too-many', + channelId: 'ch-img-too-many', + prompt: 'Test', + imagePaths: [pngPath, pngPath, pngPath, pngPath, pngPath], + }), + ).toThrow(/Too many images/); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('reports an unreadable image as an error instead of sending a request', async () => { + const { win, messages } = makeMockWin(); + + askAboutCodeMinimax(win, { + requestId: 'img-missing', + channelId: 'ch-img-missing', + prompt: 'Test', + imagePaths: [path.join(os.tmpdir(), 'parallel-code-does-not-exist.png')], + }); + + await waitForDone(messages); + + const errors = messages.filter((m) => (m as Record).type === 'error'); + expect(errors).toHaveLength(1); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('rejects an oversized image before reading it', async () => { + const { win, messages } = makeMockWin(); + + askAboutCodeMinimax(win, { + requestId: 'img-oversized', + channelId: 'ch-img-oversized', + prompt: 'Test', + imagePaths: [oversizedPath], + }); + + await waitForDone(messages); + + const errors = messages.filter( + (message) => (message as Record).type === 'error', + ); + expect(errors).toHaveLength(1); + expect((errors[0] as Record).text).toMatch(/Image too large/); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('rejects a path that is not a regular file instead of blocking on it', async () => { + const { win, messages } = makeMockWin(); + + askAboutCodeMinimax(win, { + requestId: 'img-fifo', + channelId: 'ch-img-fifo', + prompt: 'Test', + imagePaths: [fifoPath], + }); + + await waitForDone(messages); + + const errors = messages.filter( + (message) => (message as Record).type === 'error', + ); + expect(errors).toHaveLength(1); + expect((errors[0] as Record).text).toMatch(/Not a regular file/); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); + describe('cancelAskAboutCodeMinimax', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/electron/ipc/ask-code-minimax.ts b/electron/ipc/ask-code-minimax.ts index 0f262296..287abeb0 100644 --- a/electron/ipc/ask-code-minimax.ts +++ b/electron/ipc/ask-code-minimax.ts @@ -1,3 +1,5 @@ +import fs from 'fs'; +import path from 'path'; import type { BrowserWindow } from 'electron'; import { debug as logDebug } from '../log.js'; import { @@ -8,16 +10,120 @@ import { assertCanStart, assertPromptWithinLimit, } from './request-registry.js'; +import { isSupportedAskCodeImageExtension } from '../shared/ask-code-image.js'; interface MinimaxAskCodeRequest { requestId: string; channelId: string; prompt: string; + /** + * Absolute paths of images to send alongside the prompt. The app already + * resolves pasted and dropped images to temp files, so a request carries + * those paths rather than the bytes themselves. + */ + imagePaths?: string[]; } const MINIMAX_API_URL = 'https://api.minimax.io/v1/chat/completions'; export const MINIMAX_MODEL = 'MiniMax-M2.7'; +/** Model used when a request carries image input. */ +export const MINIMAX_IMAGE_INPUT_MODEL = 'MiniMax-M3'; + +/** Image input is capped separately from the prompt: the bytes never count against it. */ +const MAX_IMAGES_PER_REQUEST = 4; +const MAX_IMAGE_BYTES = 10 * 1024 * 1024; +// Keep base64 data plus JSON framing below the provider's 64 MB body limit. +// Unreachable at the constants above (4 × 10 MB = 40 MB) — this is a tripwire +// so raising either one fails here rather than at the provider. +const MAX_TOTAL_IMAGE_BYTES = 46 * 1024 * 1024; + +/** A chat message content part in the chat completions request schema. */ +type MinimaxContentPart = + | { type: 'text'; text: string } + | { type: 'image_url'; image_url: { url: string } }; + +/** Rejects unsupported or oversized image input before a request is started. */ +function assertImagesSupported(imagePaths: string[]): void { + if (imagePaths.length > MAX_IMAGES_PER_REQUEST) { + throw new Error( + `Too many images (${imagePaths.length}, max ${MAX_IMAGES_PER_REQUEST} per question)`, + ); + } + for (const imagePath of imagePaths) { + if (!isSupportedAskCodeImageExtension(imagePath)) { + throw new Error(`Unsupported image type: ${path.basename(imagePath)}`); + } + } +} + +/** Identify supported image bytes without trusting the file extension. */ +function detectImageMimeType(bytes: Buffer): string | undefined { + if (bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) return 'image/png'; + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return 'image/jpeg'; + } + const header = bytes.subarray(0, 12).toString('ascii'); + if (header.startsWith('GIF87a') || header.startsWith('GIF89a')) return 'image/gif'; + if (header.startsWith('RIFF') && header.slice(8, 12) === 'WEBP') return 'image/webp'; + return undefined; +} + +/** Check individual and aggregate sizes before buffering image data. */ +async function assertImageSizes(imagePaths: string[]): Promise { + let totalBytes = 0; + for (const imagePath of imagePaths) { + const stats = await fs.promises.stat(imagePath); + // A FIFO or device stats as size 0 and would slip past the caps below, but + // readFile has no abort signal — it would block past the request timeout + // and leak the descriptor, since only fetch sees the AbortController. + if (!stats.isFile()) { + throw new Error(`Not a regular file: ${path.basename(imagePath)}`); + } + const { size } = stats; + if (size > MAX_IMAGE_BYTES) { + throw new Error( + `Image too large: ${path.basename(imagePath)} (${size} bytes, max ${MAX_IMAGE_BYTES})`, + ); + } + totalBytes += size; + } + if (totalBytes > MAX_TOTAL_IMAGE_BYTES) { + throw new Error(`Attached images are too large (${totalBytes} bytes total)`); + } +} + +/** Reads an image from disk into the data URL form the chat API expects. */ +async function imageDataUrl(imagePath: string): Promise { + const bytes = await fs.promises.readFile(imagePath); + const mimeType = detectImageMimeType(bytes); + if (!mimeType) throw new Error(`Unsupported image data: ${path.basename(imagePath)}`); + if (bytes.byteLength > MAX_IMAGE_BYTES) { + throw new Error( + `Image too large: ${path.basename(imagePath)} (${bytes.byteLength} bytes, max ${MAX_IMAGE_BYTES})`, + ); + } + return `data:${mimeType};base64,${bytes.toString('base64')}`; +} + +/** + * Builds the user message content: a plain string while the question is text + * only, and text plus image parts once images are attached. + */ +async function buildUserContent( + prompt: string, + imagePaths: string[], +): Promise { + if (imagePaths.length === 0) return prompt; + await assertImageSizes(imagePaths); + + const parts: MinimaxContentPart[] = [{ type: 'text', text: prompt }]; + for (const imagePath of imagePaths) { + parts.push({ type: 'image_url', image_url: { url: await imageDataUrl(imagePath) } }); + } + return parts; +} + const activeRequests = new RequestRegistry({ maxConcurrent: ASK_CODE_MAX_CONCURRENT, timeoutMs: ASK_CODE_TIMEOUT_MS, @@ -32,6 +138,7 @@ export function setMinimaxApiKey(key: string): void { export function askAboutCodeMinimax(win: BrowserWindow, args: MinimaxAskCodeRequest): void { const { requestId, channelId, prompt } = args; + const imagePaths = args.imagePaths ?? []; const apiKey = storedApiKey; if (!apiKey) { @@ -39,10 +146,13 @@ export function askAboutCodeMinimax(win: BrowserWindow, args: MinimaxAskCodeRequ } assertPromptWithinLimit(prompt); + assertImagesSupported(imagePaths); assertCanStart(activeRequests, requestId); cancelAskAboutCodeMinimax(requestId); + const model = imagePaths.length > 0 ? MINIMAX_IMAGE_INPUT_MODEL : MINIMAX_MODEL; + const controller = new AbortController(); const send = (msg: unknown) => { @@ -55,28 +165,31 @@ export function askAboutCodeMinimax(win: BrowserWindow, args: MinimaxAskCodeRequ request.abort(), ); - fetch(MINIMAX_API_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model: MINIMAX_MODEL, - messages: [ - { - role: 'system', - content: 'Answer concisely about the selected code. Use markdown.', + buildUserContent(prompt, imagePaths) + .then((content) => + fetch(MINIMAX_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, }, - { role: 'user', content: prompt }, - ], - // MiniMax temperature must be in (0.0, 1.0] - temperature: 0.3, - max_tokens: 2048, - stream: true, - }), - signal: controller.signal, - }) + body: JSON.stringify({ + model, + messages: [ + { + role: 'system', + content: 'Answer concisely about the selected code. Use markdown.', + }, + { role: 'user', content }, + ], + // MiniMax temperature must be in (0.0, 1.0] + temperature: 0.3, + max_tokens: 2048, + stream: true, + }), + signal: controller.signal, + }), + ) .then(async (res) => { if (!res.ok || !res.body) { const text = await res.text().catch(() => `HTTP ${res.status}`); diff --git a/electron/ipc/ask-code.test.ts b/electron/ipc/ask-code.test.ts new file mode 100644 index 00000000..38abe436 --- /dev/null +++ b/electron/ipc/ask-code.test.ts @@ -0,0 +1,81 @@ +import { EventEmitter } from 'events'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// vi.mock factories are hoisted above the module body, so the spies they +// close over have to be hoisted with them. +const { mockSpawn, mockAskMinimax } = vi.hoisted(() => ({ + mockSpawn: vi.fn(), + mockAskMinimax: vi.fn(), +})); + +vi.mock('child_process', () => ({ spawn: mockSpawn })); +vi.mock('./pty.js', () => ({ validateCommand: vi.fn(), ENV_BLOCK_LIST: new Set() })); +vi.mock('./env-file.js', () => ({ loadEnvFile: vi.fn(() => ({})) })); + +vi.mock('./ask-code-minimax.js', () => ({ + askAboutCodeMinimax: (...args: unknown[]) => mockAskMinimax(...args), + cancelAskAboutCodeMinimax: vi.fn(), + isMinimaxRequestActive: vi.fn(() => false), +})); + +import { askAboutCode } from './ask-code.js'; + +function makeMockProc() { + const proc = new EventEmitter() as EventEmitter & Record; + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.kill = vi.fn(); + return proc; +} + +function makeMockWin() { + return { + isDestroyed: () => false, + webContents: { send: vi.fn() }, + } as unknown as Parameters[0]; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockSpawn.mockImplementation(() => makeMockProc()); +}); + +describe('askAboutCode provider routing', () => { + it('drops imagePaths on the claude CLI path instead of passing them through', () => { + askAboutCode(makeMockWin(), { + requestId: 'claude-img', + channelId: 'ch-claude-img', + prompt: 'What does this do?', + cwd: '/repo', + provider: 'claude', + imagePaths: ['/tmp/shot.png'], + }); + + expect(mockSpawn).toHaveBeenCalledOnce(); + const [command, argv] = mockSpawn.mock.calls[0] as [string, string[]]; + expect(command).toBe('claude'); + // The CLI takes a text prompt only — an image path must never reach argv. + expect(argv).toContain('What does this do?'); + expect(argv.join(' ')).not.toContain('/tmp/shot.png'); + expect(mockAskMinimax).not.toHaveBeenCalled(); + }); + + it('forwards imagePaths to the minimax backend without spawning the CLI', () => { + askAboutCode(makeMockWin(), { + requestId: 'minimax-img', + channelId: 'ch-minimax-img', + prompt: 'What does this show?', + cwd: '/repo', + provider: 'minimax', + imagePaths: ['/tmp/shot.png'], + }); + + expect(mockSpawn).not.toHaveBeenCalled(); + expect(mockAskMinimax).toHaveBeenCalledWith(expect.anything(), { + requestId: 'minimax-img', + channelId: 'ch-minimax-img', + prompt: 'What does this show?', + imagePaths: ['/tmp/shot.png'], + }); + }); +}); diff --git a/electron/ipc/ask-code.ts b/electron/ipc/ask-code.ts index e8bcc568..9c5a952b 100644 --- a/electron/ipc/ask-code.ts +++ b/electron/ipc/ask-code.ts @@ -26,6 +26,8 @@ interface AskCodeRequest { provider?: AskCodeProvider; /** Env file configured for the Claude Code agent, if any. */ envFile?: string; + /** Absolute paths of images attached to the question. */ + imagePaths?: string[]; } const activeRequests = new RequestRegistry({ @@ -34,12 +36,13 @@ const activeRequests = new RequestRegistry({ }); export function askAboutCode(win: BrowserWindow, args: AskCodeRequest): void { - const { requestId, channelId, prompt, cwd, provider, envFile } = args; + const { requestId, channelId, prompt, cwd, provider, envFile, imagePaths } = args; - // Route to MiniMax backend when configured + // Route to MiniMax backend when configured. Image input is wired for this + // provider only; the CLI path below takes a text prompt. if (provider === 'minimax') { activeRequests.cancel(requestId); - askAboutCodeMinimax(win, { requestId, channelId, prompt }); + askAboutCodeMinimax(win, { requestId, channelId, prompt, imagePaths }); return; } diff --git a/electron/ipc/register.test.ts b/electron/ipc/register.test.ts index c6a228f2..67c82da3 100644 --- a/electron/ipc/register.test.ts +++ b/electron/ipc/register.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { branchNameArg, + createClipboardImagePath, openExternalHttpUrl, optionalBaseBranch, projectRootArg, @@ -9,6 +10,17 @@ import { worktreePathArg, } from './register.js'; +describe('createClipboardImagePath', () => { + it('creates a unique PNG path for every clipboard image', () => { + const first = createClipboardImagePath(); + const second = createClipboardImagePath(); + + expect(first).not.toBe(second); + expect(first).toMatch(/parallel-code-drop-.*-clipboard\.png$/); + expect(second).toMatch(/parallel-code-drop-.*-clipboard\.png$/); + }); +}); + describe('selectMcpJsonDir', () => { it('returns worktreePath when defined', () => { expect(selectMcpJsonDir('/worktrees/my-task', '/project')).toBe('/worktrees/my-task'); diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index aadab018..3e7d736f 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -380,6 +380,11 @@ function sanitizeDroppedName(name: string): string { return `parallel-code-drop-${stamp}.png`; } +/** Unique path for a raster image copied from the clipboard. */ +export function createClipboardImagePath(): string { + return path.join(os.tmpdir(), sanitizeDroppedName('clipboard.png')); +} + /** * Create a leading+trailing throttled event forwarder. * Fires immediately, suppresses for `intervalMs`, then fires once more @@ -947,6 +952,13 @@ export function registerAllHandlers(win: BrowserWindow): void { const provider: string | undefined = typeof args.provider === 'string' ? args.provider : undefined; assertOptionalString(args.envFile, 'envFile'); + const rawImagePaths: unknown = args.imagePaths; + let imagePaths: string[] | undefined; + if (rawImagePaths !== undefined) { + assertStringArray(rawImagePaths, 'imagePaths'); + for (const imagePath of rawImagePaths) validatePath(imagePath, 'imagePath'); + imagePaths = rawImagePaths; + } askAboutCode(win, { requestId: args.requestId, channelId: args.onOutput.__CHANNEL_ID__, @@ -954,6 +966,7 @@ export function registerAllHandlers(win: BrowserWindow): void { cwd: args.cwd, provider: provider === 'minimax' ? 'minimax' : 'claude', envFile: args.envFile, + imagePaths, }); }); @@ -986,8 +999,6 @@ export function registerAllHandlers(win: BrowserWindow): void { }); // --- Clipboard --- - const clipboardImagePath = path.join(os.tmpdir(), 'parallel-code-clipboard.png'); - // Resolve the most useful representation of the current clipboard contents // for pasting into a terminal. Order of preference: // 1. file references (Finder copy, Nautilus copy, etc.) → return absolute path @@ -1009,6 +1020,7 @@ export function registerAllHandlers(win: BrowserWindow): void { const img = clipboard.readImage(); if (!img.isEmpty()) { const buf = img.toPNG(); + const clipboardImagePath = createClipboardImagePath(); await fs.promises.writeFile(clipboardImagePath, buf); return { kind: 'image', path: clipboardImagePath }; } diff --git a/electron/shared/ask-code-image.ts b/electron/shared/ask-code-image.ts new file mode 100644 index 00000000..06a04ec4 --- /dev/null +++ b/electron/shared/ask-code-image.ts @@ -0,0 +1,18 @@ +/** + * Extensions the Ask Code image input accepts. This is only a cheap pre-filter + * so the renderer can reject a paste without touching disk — the MIME type + * actually sent to the provider is derived from the file's signature bytes. + */ +const ASK_CODE_IMAGE_EXTENSIONS: ReadonlySet = new Set([ + '.png', + '.jpg', + '.jpeg', + '.webp', + '.gif', +]); + +/** Whether a file path carries an extension the Ask Code image input accepts. */ +export function isSupportedAskCodeImageExtension(filePath: string): boolean { + const match = /\.[^./\\]+$/.exec(filePath); + return match !== null && ASK_CODE_IMAGE_EXTENSIONS.has(match[0].toLowerCase()); +} diff --git a/src/components/AskCodeCard.tsx b/src/components/AskCodeCard.tsx index 6b74ff9b..1d58dd54 100644 --- a/src/components/AskCodeCard.tsx +++ b/src/components/AskCodeCard.tsx @@ -14,6 +14,8 @@ interface AskCodeCardProps { endLine: number; selectedText: string; worktreePath: string; + /** Absolute paths of images attached to the question, if any. */ + imagePaths?: string[]; onDismiss: () => void; } @@ -65,6 +67,7 @@ export function AskCodeCard(props: AskCodeCardProps) { onOutput: channel, provider: store.askCodeProvider, envFile: store.agentEnvFiles['claude-code'], + imagePaths: props.imagePaths?.length ? props.imagePaths : undefined, }).catch((err: unknown) => { setError(errMessage(err)); setLoading(false); diff --git a/src/components/InlineInput.client.test.tsx b/src/components/InlineInput.client.test.tsx new file mode 100644 index 00000000..fc93eebe --- /dev/null +++ b/src/components/InlineInput.client.test.tsx @@ -0,0 +1,92 @@ +import { render } from 'solid-js/web'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { InlineInput } from './InlineInput'; +import { invoke } from '../lib/ipc'; +import { setStore } from '../store/core'; + +vi.mock('../lib/ipc', () => ({ invoke: vi.fn() })); + +const disposers: Array<() => void> = []; + +/** Paste a clipboard image and let the async resolver settle. */ +async function pasteImage(input: HTMLInputElement) { + const event = new Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'clipboardData', { + value: { items: [{ type: 'image/png', kind: 'file' }] }, + }); + input.dispatchEvent(event); + await Promise.resolve(); + await Promise.resolve(); +} + +function mount(onSubmit: (text: string, mode: string, imagePaths?: string[]) => void) { + const container = document.createElement('div'); + document.body.append(container); + disposers.push(render(() => {}} />, container)); + return container; +} + +function modeButton(container: HTMLElement, label: string) { + return Array.from(container.querySelectorAll('button')).find((b) => b.textContent === label); +} + +function attachmentChip(container: HTMLElement) { + return Array.from(container.querySelectorAll('button')).find((b) => + /image[s]? ×$/.test(b.textContent ?? ''), + ); +} + +beforeEach(() => { + setStore('askCodeProvider', 'minimax'); + vi.mocked(invoke).mockResolvedValue({ kind: 'image', path: '/tmp/shot.png' }); +}); + +afterEach(() => { + while (disposers.length > 0) disposers.pop()?.(); + document.body.replaceChildren(); + setStore('askCodeProvider', 'claude'); + vi.mocked(invoke).mockReset(); +}); + +describe('InlineInput image attachment', () => { + it('does not advertise an attachment in Comment mode, where submit discards it', async () => { + const onSubmit = vi.fn(); + const container = mount(onSubmit); + const input = container.querySelector('input') as HTMLInputElement; + + // Comment mode is the default, and it is the mode the user lands in when + // the inline input opens from a selection. + await pasteImage(input); + + expect(attachmentChip(container)).toBeUndefined(); + // Hidden chip must not mean silence: the accepted paste says where it went. + expect(container.querySelector('[aria-live]')?.textContent).toBe( + 'Image attached — switch to Ask to send it.', + ); + + input.value = 'why is this here?'; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + + expect(onSubmit).toHaveBeenCalledWith('why is this here?', 'review', undefined); + }); + + it('keeps the pasted image across a switch to Ask mode and sends it', async () => { + const onSubmit = vi.fn(); + const container = mount(onSubmit); + const input = container.querySelector('input') as HTMLInputElement; + + await pasteImage(input); + modeButton(container, 'Ask')?.click(); + + expect(attachmentChip(container)?.textContent).toBe('1 image ×'); + // The chip now carries the state, so the notice stands down. + expect(container.querySelector('[aria-live]')).toBeNull(); + + input.value = 'what does this show?'; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + + expect(onSubmit).toHaveBeenCalledWith('what does this show?', 'ask', ['/tmp/shot.png']); + }); +}); diff --git a/src/components/InlineInput.tsx b/src/components/InlineInput.tsx index d2c0e057..9fda3e27 100644 --- a/src/components/InlineInput.tsx +++ b/src/components/InlineInput.tsx @@ -1,18 +1,45 @@ -import { createSignal, onCleanup, onMount } from 'solid-js'; +import { createSignal, onCleanup, onMount, Show } from 'solid-js'; import { theme } from '../lib/theme'; import { sf } from '../lib/fontScale'; +import { invoke } from '../lib/ipc'; +import { IPC } from '../../electron/ipc/channels'; +import { store } from '../store/store'; +import { warn as logWarn } from '../lib/log'; import type { DiffInteractionMode } from './review-types'; +import { isSupportedAskCodeImageExtension } from './ask-code-image'; interface InlineInputProps { - onSubmit: (text: string, mode: DiffInteractionMode) => void; + onSubmit: (text: string, mode: DiffInteractionMode, imagePaths?: string[]) => void; onDismiss: () => void; } +/** Shape of the resolved clipboard content returned by the main process. */ +interface ResolvedPaste { + kind: string; + path?: string; +} + export function InlineInput(props: InlineInputProps) { const [text, setText] = createSignal(''); const [mode, setMode] = createSignal('review'); + const [imagePaths, setImagePaths] = createSignal([]); + const [imagePasteHint, setImagePasteHint] = createSignal(''); let inputRef: HTMLInputElement | undefined; + /** Images are only sent to a provider whose model accepts image input. */ + const imageInputEnabled = () => mode() === 'ask' && store.askCodeProvider === 'minimax'; + + /** + * A paste that can't be attached yet must say so. Attaching works in either + * mode so it can precede the switch to Ask, but only Ask sends — without this + * the chip is hidden and an accepted image looks like it went nowhere. + */ + const pasteNotice = () => + imagePasteHint() || + (imagePaths().length > 0 && !imageInputEnabled() + ? 'Image attached — switch to Ask to send it.' + : ''); + onMount(() => { requestAnimationFrame(() => inputRef?.focus()); const onGlobalKeyDown = (e: KeyboardEvent) => { @@ -32,7 +59,40 @@ export function InlineInput(props: InlineInputProps) { function submit() { const t = text().trim(); - if (t) props.onSubmit(t, mode()); + if (!t) return; + props.onSubmit(t, mode(), imageInputEnabled() ? imagePaths() : undefined); + } + + /** + * Attaches a pasted image to the question. The main process already turns + * clipboard images into temp files, so the same path is reused here. + */ + function handlePaste(e: ClipboardEvent) { + if (store.askCodeProvider !== 'minimax') return; + const hasImage = Array.from(e.clipboardData?.items ?? []).some((item) => + item.type.startsWith('image/'), + ); + if (!hasImage) return; + + e.preventDefault(); + setImagePasteHint(''); + invoke(IPC.ResolveClipboardPaste) + .then((paste) => { + if ( + paste.path && + (paste.kind === 'image' || + (paste.kind === 'file' && isSupportedAskCodeImageExtension(paste.path))) + ) { + const attached = paste.path; + setImagePaths((prev) => (prev.includes(attached) ? prev : [...prev, attached])); + } else { + setImagePasteHint('Only PNG, JPEG, WEBP, and GIF images can be attached.'); + } + }) + .catch((err: unknown) => { + logWarn('askCode.paste', 'ResolveClipboardPaste failed', { err }); + setImagePasteHint('Could not attach the clipboard image.'); + }); } function handleKeyDown(e: KeyboardEvent) { @@ -107,6 +167,7 @@ export function InlineInput(props: InlineInputProps) { value={text()} onInput={(e) => setText(e.currentTarget.value)} onKeyDown={handleKeyDown} + onPaste={handlePaste} style={{ flex: '1', background: theme.bgInput, @@ -120,6 +181,39 @@ export function InlineInput(props: InlineInputProps) { }} /> + {/* Attached images */} + {/* Only shown when submit would actually send them — see imageInputEnabled. */} + 0}> + + + + + {(hint) => ( + + {hint()} + + )} + + {/* Submit button */}