diff --git a/README.md b/README.md index 4bbf5eec..3885506d 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ - **See every session in one place** — switch context without losing momentum. - **Control everything keyboard-first** — every action has a shortcut, mouse optional. - **Monitor progress from your phone** — scan a QR code, watch agents work over Wi-Fi or Tailscale. +- **Ask about code with any LLM** — the inline code Q&A feature supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (default) or [MiniMax](https://www.minimax.io/) M2.7 (204K context) — configurable in Settings.
How does it compare? diff --git a/electron/ipc/ask-code-minimax.test.ts b/electron/ipc/ask-code-minimax.test.ts new file mode 100644 index 00000000..8a73d51a --- /dev/null +++ b/electron/ipc/ask-code-minimax.test.ts @@ -0,0 +1,305 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock fetch globally +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +import { + askAboutCodeMinimax, + cancelAskAboutCodeMinimax, + MINIMAX_MODEL, + setMinimaxApiKey, +} from './ask-code-minimax.js'; + +function makeMockWin() { + const messages: unknown[] = []; + const win = { + isDestroyed: vi.fn().mockReturnValue(false), + webContents: { + send: vi.fn().mockImplementation((_ch: string, msg: unknown) => { + messages.push(msg); + }), + }, + } as unknown as import('electron').BrowserWindow; + return { win, messages }; +} + +/** Wait until a 'done' message appears in the messages array. */ +function waitForDone(messages: unknown[], timeoutMs = 3000): Promise { + return new Promise((resolve, reject) => { + const deadline = Date.now() + timeoutMs; + function check() { + if (messages.some((m) => (m as Record).type === 'done')) { + resolve(); + return; + } + if (Date.now() >= deadline) { + reject(new Error('Timed out waiting for done message')); + return; + } + setTimeout(check, 10); + } + check(); + }); +} + +function makeStreamResponse(sseText: string): Response { + const encoder = new TextEncoder(); + const bytes = encoder.encode(sseText); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); + return new Response(stream, { status: 200 }); +} + +function sseChunk(content: string): string { + return `data: ${JSON.stringify({ choices: [{ delta: { content } }] })}\n\n`; +} + +describe('askAboutCodeMinimax', () => { + beforeEach(() => { + vi.clearAllMocks(); + setMinimaxApiKey('test-key'); + }); + + it('throws if prompt exceeds max length', () => { + const { win } = makeMockWin(); + const longPrompt = 'x'.repeat(50_001); + expect(() => + askAboutCodeMinimax(win, { + requestId: 'r1', + channelId: 'ch1', + prompt: longPrompt, + }), + ).toThrow(/Prompt too long/); + }); + + it('sends chunk messages for each SSE delta', async () => { + const { win, messages } = makeMockWin(); + + const sseText = sseChunk('Hello') + sseChunk(', world') + 'data: [DONE]\n\n'; + mockFetch.mockResolvedValueOnce(makeStreamResponse(sseText)); + + askAboutCodeMinimax(win, { + requestId: 'r2', + channelId: 'ch2', + prompt: 'Explain this code', + }); + + await waitForDone(messages); + + const chunkMsgs = messages.filter((m) => (m as Record).type === 'chunk'); + expect(chunkMsgs).toHaveLength(2); + expect((chunkMsgs[0] as Record).text).toBe('Hello'); + expect((chunkMsgs[1] as Record).text).toBe(', world'); + + const doneMsgs = messages.filter((m) => (m as Record).type === 'done'); + expect(doneMsgs).toHaveLength(1); + expect((doneMsgs[0] as Record).exitCode).toBe(0); + }); + + it('sends error message on non-ok HTTP response', async () => { + const { win, messages } = makeMockWin(); + + mockFetch.mockResolvedValueOnce(new Response('Unauthorized', { status: 401 })); + + askAboutCodeMinimax(win, { + requestId: 'r3', + channelId: 'ch3', + prompt: 'What is this?', + }); + + await waitForDone(messages); + + const errMsgs = messages.filter((m) => (m as Record).type === 'error'); + expect(errMsgs.length).toBeGreaterThan(0); + expect((errMsgs[0] as Record).text).toMatch(/401/); + + const doneMsgs = messages.filter((m) => (m as Record).type === 'done'); + expect((doneMsgs[0] as Record).exitCode).toBe(1); + }); + + it('sends error message when fetch rejects', async () => { + const { win, messages } = makeMockWin(); + + mockFetch.mockRejectedValueOnce(new Error('Network failure')); + + askAboutCodeMinimax(win, { + requestId: 'r4', + channelId: 'ch4', + prompt: 'Explain', + }); + + await waitForDone(messages); + + const errMsgs = messages.filter((m) => (m as Record).type === 'error'); + expect(errMsgs.length).toBeGreaterThan(0); + expect((errMsgs[0] as Record).text).toMatch(/Network failure/); + }); + + it('sends correct Authorization header with Bearer token', async () => { + const { win, messages } = makeMockWin(); + + mockFetch.mockResolvedValueOnce(makeStreamResponse('data: [DONE]\n\n')); + + setMinimaxApiKey('my-secret-key'); + askAboutCodeMinimax(win, { + requestId: 'r5', + channelId: 'ch5', + prompt: 'Explain', + }); + + await waitForDone(messages); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('minimax.io'), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer my-secret-key', + }), + }), + ); + }); + + it('uses MiniMax-M2.7 model', async () => { + const { win, messages } = makeMockWin(); + + mockFetch.mockResolvedValueOnce(makeStreamResponse('data: [DONE]\n\n')); + + askAboutCodeMinimax(win, { + requestId: 'r6', + channelId: 'ch6', + prompt: 'Test', + }); + + await waitForDone(messages); + + const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) as { + model: string; + }; + expect(body.model).toBe(MINIMAX_MODEL); + }); + + it('uses temperature in MiniMax allowed range (0, 1]', async () => { + const { win, messages } = makeMockWin(); + + mockFetch.mockResolvedValueOnce(makeStreamResponse('data: [DONE]\n\n')); + + askAboutCodeMinimax(win, { + requestId: 'r7', + channelId: 'ch7', + prompt: 'Test', + }); + + await waitForDone(messages); + + const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) as { + temperature: number; + }; + expect(body.temperature).toBeGreaterThan(0); + expect(body.temperature).toBeLessThanOrEqual(1); + }); + + it('uses streaming mode', async () => { + const { win, messages } = makeMockWin(); + + mockFetch.mockResolvedValueOnce(makeStreamResponse('data: [DONE]\n\n')); + + askAboutCodeMinimax(win, { + requestId: 'r8', + channelId: 'ch8', + prompt: 'Test', + }); + + await waitForDone(messages); + + const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) as { + stream: boolean; + }; + expect(body.stream).toBe(true); + }); + + it('does not send to destroyed window', async () => { + const { win, messages } = makeMockWin(); + (win.isDestroyed as ReturnType).mockReturnValue(true); + + mockFetch.mockResolvedValueOnce(makeStreamResponse(sseChunk('Hello') + 'data: [DONE]\n\n')); + + askAboutCodeMinimax(win, { + requestId: 'r9', + channelId: 'ch9', + prompt: 'Test', + }); + + // Small delay to let the async chain run + await new Promise((r) => setTimeout(r, 100)); + expect(messages).toHaveLength(0); + }); + + it('includes a system prompt instructing concise markdown answers', async () => { + const { win, messages } = makeMockWin(); + + mockFetch.mockResolvedValueOnce(makeStreamResponse('data: [DONE]\n\n')); + + askAboutCodeMinimax(win, { + requestId: 'r10', + channelId: 'ch10', + prompt: 'Explain this', + }); + + await waitForDone(messages); + + const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) as { + messages: Array<{ role: string; content: string }>; + }; + const systemMsg = body.messages.find((m) => m.role === 'system'); + expect(systemMsg).toBeDefined(); + expect(systemMsg?.content).toMatch(/markdown/i); + }); +}); + +describe('cancelAskAboutCodeMinimax', () => { + beforeEach(() => { + vi.clearAllMocks(); + setMinimaxApiKey('test-key'); + }); + + it('cancels a pending request without sending an error message', async () => { + const { win, messages } = makeMockWin(); + + // Simulate a slow response that never closes + const neverEnding = new ReadableStream({ + start(controller) { + // Enqueue one empty byte so the response is ok + controller.enqueue(new Uint8Array(0)); + // never close — reader.read() will block + }, + }); + mockFetch.mockResolvedValueOnce(new Response(neverEnding, { status: 200 })); + + askAboutCodeMinimax(win, { + requestId: 'cancel-1', + channelId: 'ch-cancel', + prompt: 'Test', + }); + + // Give fetch time to start + await new Promise((r) => setTimeout(r, 20)); + + cancelAskAboutCodeMinimax('cancel-1'); + + // Wait for done message (AbortError -> done sent without error) + await waitForDone(messages); + + const errMsgs = messages.filter((m) => (m as Record).type === 'error'); + // AbortError should NOT produce an error message + expect(errMsgs).toHaveLength(0); + }); + + it('is a no-op for unknown requestId', () => { + expect(() => cancelAskAboutCodeMinimax('unknown-id')).not.toThrow(); + }); +}); diff --git a/electron/ipc/ask-code-minimax.ts b/electron/ipc/ask-code-minimax.ts new file mode 100644 index 00000000..d8fc8311 --- /dev/null +++ b/electron/ipc/ask-code-minimax.ts @@ -0,0 +1,180 @@ +import type { BrowserWindow } from 'electron'; + +interface MinimaxAskCodeRequest { + requestId: string; + channelId: string; + prompt: string; +} + +const MAX_PROMPT_LENGTH = 50_000; +const MAX_CONCURRENT = 5; +const TIMEOUT_MS = 120_000; +const MINIMAX_API_URL = 'https://api.minimax.io/v1/chat/completions'; +export const MINIMAX_MODEL = 'MiniMax-M2.7'; + +const activeRequests = new Map(); +const activeTimers = new Map>(); + +/** Main-process storage for the MiniMax API key. Never sent back to the renderer. */ +let storedApiKey = ''; + +export function setMinimaxApiKey(key: string): void { + storedApiKey = key.trim(); +} + +export function getMinimaxApiKey(): string { + return storedApiKey; +} + +export function askAboutCodeMinimax(win: BrowserWindow, args: MinimaxAskCodeRequest): void { + const { requestId, channelId, prompt } = args; + const apiKey = storedApiKey; + + if (!apiKey) { + throw new Error('MiniMax API key is not set. Please configure it in Settings.'); + } + + if (prompt.length > MAX_PROMPT_LENGTH) { + throw new Error(`Prompt too long (${prompt.length} chars, max ${MAX_PROMPT_LENGTH})`); + } + if (activeRequests.size >= MAX_CONCURRENT) { + throw new Error('Too many concurrent ask-about-code requests'); + } + + cancelAskAboutCodeMinimax(requestId); + + const controller = new AbortController(); + activeRequests.set(requestId, controller); + + const send = (msg: unknown) => { + if (!win.isDestroyed()) { + win.webContents.send(`channel:${channelId}`, msg); + } + }; + + let finished = false; + + function cleanup() { + activeRequests.delete(requestId); + const timer = activeTimers.get(requestId); + if (timer) { + clearTimeout(timer); + activeTimers.delete(requestId); + } + } + + // Safety timeout: kill after 2 minutes + const timer = setTimeout(() => { + activeTimers.delete(requestId); + if (activeRequests.has(requestId)) { + finished = true; + send({ type: 'error', text: 'Request timed out after 2 minutes.' }); + cancelAskAboutCodeMinimax(requestId); + send({ type: 'done', exitCode: 1 }); + } + }, TIMEOUT_MS); + activeTimers.set(requestId, timer); + + 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.', + }, + { role: 'user', content: prompt }, + ], + // 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}`); + throw new Error(`MiniMax API error (${res.status}): ${text}`); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + + // When the AbortController fires, cancel the reader so reader.read() resolves + let aborted = false; + const onAbort = () => { + aborted = true; + reader.cancel().catch(() => {}); + }; + controller.signal.addEventListener('abort', onAbort, { once: true }); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done || aborted) break; + buf += decoder.decode(value, { stream: true }); + const lines = buf.split('\n'); + buf = lines.pop() ?? ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed === 'data: [DONE]') continue; + if (!trimmed.startsWith('data:')) continue; + try { + const json = JSON.parse(trimmed.slice(5).trim()) as { + choices?: Array<{ delta?: { content?: string } }>; + }; + const delta = json.choices?.[0]?.delta?.content; + if (delta) send({ type: 'chunk', text: delta }); + } catch { + // ignore parse errors in SSE stream + } + } + } + } finally { + controller.signal.removeEventListener('abort', onAbort); + } + + cleanup(); + if (!finished) { + finished = true; + send({ type: 'done', exitCode: 0, cancelled: aborted }); + } + }) + .catch((err: unknown) => { + cleanup(); + if (!finished) { + finished = true; + if (err instanceof Error && err.name === 'AbortError') { + // request was cancelled — send done without error, neutral exit code + send({ type: 'done', exitCode: 0, cancelled: true }); + } else { + send({ type: 'error', text: err instanceof Error ? err.message : String(err) }); + send({ type: 'done', exitCode: 1 }); + } + } + }); +} + +export function cancelAskAboutCodeMinimax(requestId: string): void { + const controller = activeRequests.get(requestId); + if (controller) { + controller.abort(); + activeRequests.delete(requestId); + } + const timer = activeTimers.get(requestId); + if (timer) { + clearTimeout(timer); + activeTimers.delete(requestId); + } +} + +export function isMinimaxRequestActive(requestId: string): boolean { + return activeRequests.has(requestId); +} diff --git a/electron/ipc/ask-code.ts b/electron/ipc/ask-code.ts index 447e659b..45fc4cb7 100644 --- a/electron/ipc/ask-code.ts +++ b/electron/ipc/ask-code.ts @@ -1,12 +1,20 @@ import { spawn, type ChildProcess } from 'child_process'; import type { BrowserWindow } from 'electron'; import { validateCommand } from './pty.js'; +import { + askAboutCodeMinimax, + cancelAskAboutCodeMinimax, + isMinimaxRequestActive, +} from './ask-code-minimax.js'; + +export type AskCodeProvider = 'claude' | 'minimax'; interface AskCodeRequest { requestId: string; channelId: string; prompt: string; cwd: string; + provider?: AskCodeProvider; } const MAX_PROMPT_LENGTH = 50_000; @@ -17,7 +25,13 @@ const activeRequests = new Map(); const activeTimers = new Map>(); export function askAboutCode(win: BrowserWindow, args: AskCodeRequest): void { - const { requestId, channelId, prompt, cwd } = args; + const { requestId, channelId, prompt, cwd, provider } = args; + + // Route to MiniMax backend when configured + if (provider === 'minimax') { + askAboutCodeMinimax(win, { requestId, channelId, prompt }); + return; + } if (prompt.length > MAX_PROMPT_LENGTH) { throw new Error(`Prompt too long (${prompt.length} chars, max ${MAX_PROMPT_LENGTH})`); @@ -123,6 +137,11 @@ export function askAboutCode(win: BrowserWindow, args: AskCodeRequest): void { } export function cancelAskAboutCode(requestId: string): void { + if (isMinimaxRequestActive(requestId)) { + cancelAskAboutCodeMinimax(requestId); + return; + } + const proc = activeRequests.get(requestId); if (proc) { proc.kill('SIGTERM'); diff --git a/electron/ipc/channels.ts b/electron/ipc/channels.ts index 81988b1a..47214560 100644 --- a/electron/ipc/channels.ts +++ b/electron/ipc/channels.ts @@ -97,6 +97,7 @@ export enum IPC { // Ask about code AskAboutCode = 'ask_about_code', CancelAskAboutCode = 'cancel_ask_about_code', + SetMinimaxApiKey = 'set_minimax_api_key', // Docker CheckDockerAvailable = 'check_docker_available', diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index c4d57f1f..93587ea8 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -59,6 +59,7 @@ import { listAgents } from './agents.js'; import { saveAppState, loadAppState } from './persistence.js'; import { spawn } from 'child_process'; import { askAboutCode, cancelAskAboutCode } from './ask-code.js'; +import { setMinimaxApiKey } from './ask-code-minimax.js'; import { getSystemMonospaceFonts } from './system-fonts.js'; import path from 'path'; import { @@ -522,16 +523,24 @@ export function registerAllHandlers(win: BrowserWindow): void { }); // --- Ask about code --- + ipcMain.handle(IPC.SetMinimaxApiKey, (_e, args) => { + assertString(args.key, 'key'); + setMinimaxApiKey(args.key); + }); + ipcMain.handle(IPC.AskAboutCode, (_e, args) => { assertString(args.requestId, 'requestId'); assertString(args.prompt, 'prompt'); assertString(args.onOutput?.__CHANNEL_ID__, 'channelId'); validatePath(args.cwd, 'cwd'); + const provider: string | undefined = + typeof args.provider === 'string' ? args.provider : undefined; askAboutCode(win, { requestId: args.requestId, channelId: args.onOutput.__CHANNEL_ID__, prompt: args.prompt, cwd: args.cwd, + provider: provider === 'minimax' ? 'minimax' : 'claude', }); }); diff --git a/electron/preload.cjs b/electron/preload.cjs index 5d720dfb..2410e406 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -96,6 +96,7 @@ const ALLOWED_CHANNELS = new Set([ // Ask about code 'ask_about_code', 'cancel_ask_about_code', + 'set_minimax_api_key', // System 'get_system_fonts', // File links diff --git a/src/components/AskCodeCard.tsx b/src/components/AskCodeCard.tsx index c084bea9..cc4f419f 100644 --- a/src/components/AskCodeCard.tsx +++ b/src/components/AskCodeCard.tsx @@ -3,6 +3,7 @@ import { theme } from '../lib/theme'; import { sf } from '../lib/fontScale'; import { Channel, invoke } from '../lib/ipc'; import { IPC } from '../../electron/ipc/channels'; +import { store } from '../store/store'; interface AskCodeCardProps { requestId: string; @@ -61,6 +62,7 @@ export function AskCodeCard(props: AskCodeCardProps) { prompt, cwd: props.worktreePath, onOutput: channel, + provider: store.askCodeProvider, }).catch((err: unknown) => { setError(err instanceof Error ? err.message : String(err)); setLoading(false); diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index bf43a71b..8e3d4210 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -20,6 +20,8 @@ import { setInactiveColumnOpacity, setEditorCommand, setDockerImage, + setAskCodeProvider, + setMinimaxApiKey, } from '../store/store'; import { CustomAgentEditor } from './CustomAgentEditor'; import { mod } from '../lib/platform'; @@ -327,6 +329,93 @@ export function SettingsDialog(props: SettingsDialogProps) { +
+
+ Ask about Code +
+
+ + + + + + {store.askCodeProvider === 'minimax' + ? 'Uses MiniMax M2.7 (204K context) via the OpenAI-compatible API — no Claude Code CLI required.' + : 'Uses the claude CLI to answer questions about selected code. Requires Claude Code to be installed.'} + +
+
+
({ editorCommand: '', dockerImage: 'parallel-code-agent:latest', dockerAvailable: false, + askCodeProvider: 'claude', newTaskDropUrl: null, newTaskPrefillPrompt: null, missingProjectIds: {}, diff --git a/src/store/persistence.ts b/src/store/persistence.ts index b939d956..53f0e743 100644 --- a/src/store/persistence.ts +++ b/src/store/persistence.ts @@ -58,6 +58,7 @@ export async function saveState(): Promise { inactiveColumnOpacity: store.inactiveColumnOpacity, editorCommand: store.editorCommand || undefined, dockerImage: store.dockerImage !== 'parallel-code-agent:latest' ? store.dockerImage : undefined, + askCodeProvider: store.askCodeProvider !== 'claude' ? store.askCodeProvider : undefined, customAgents: store.customAgents.length > 0 ? [...store.customAgents] : undefined, }; @@ -203,6 +204,8 @@ interface LegacyPersistedState { inactiveColumnOpacity?: unknown; editorCommand?: unknown; dockerImage?: unknown; + askCodeProvider?: unknown; + minimaxApiKey?: unknown; customAgents?: unknown; terminals?: unknown; } @@ -333,6 +336,8 @@ export async function loadState(): Promise { ? rawDockerImage.trim() : 'parallel-code-agent:latest'; + s.askCodeProvider = raw.askCodeProvider === 'minimax' ? 'minimax' : 'claude'; + // Restore custom agents if (Array.isArray(raw.customAgents)) { s.customAgents = raw.customAgents.filter( diff --git a/src/store/store.ts b/src/store/store.ts index 2953c336..20bcc2a1 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -105,6 +105,8 @@ export { setEditorCommand, setDockerImage, setDockerAvailable, + setAskCodeProvider, + setMinimaxApiKey, setWindowState, } from './ui'; export { diff --git a/src/store/types.ts b/src/store/types.ts index 549d8687..8ebe4498 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -137,6 +137,7 @@ export interface PersistedState { inactiveColumnOpacity?: number; editorCommand?: string; dockerImage?: string; + askCodeProvider?: 'claude' | 'minimax'; customAgents?: AgentDef[]; } @@ -205,6 +206,7 @@ export interface AppStore { editorCommand: string; dockerImage: string; dockerAvailable: boolean; + askCodeProvider: 'claude' | 'minimax'; newTaskDropUrl: string | null; newTaskPrefillPrompt: { prompt: string; projectId: string | null } | null; missingProjectIds: Record; diff --git a/src/store/ui.ts b/src/store/ui.ts index e63caa7b..c22cd680 100644 --- a/src/store/ui.ts +++ b/src/store/ui.ts @@ -1,6 +1,8 @@ import { store, setStore } from './core'; import type { LookPreset } from '../lib/look'; import type { PersistedWindowState, TaskViewportVisibility } from './types'; +import { invoke } from '../lib/ipc'; +import { IPC } from '../../electron/ipc/channels'; const MIN_SCALE = 0.5; const MAX_SCALE = 2.0; @@ -89,6 +91,16 @@ export function setDockerImage(image: string): void { setStore('dockerImage', image || 'parallel-code-agent:latest'); } +export function setAskCodeProvider(provider: 'claude' | 'minimax'): void { + setStore('askCodeProvider', provider); +} + +export function setMinimaxApiKey(key: string): void { + invoke(IPC.SetMinimaxApiKey, { key: key.trim() }).catch((e) => + console.warn('Failed to set MiniMax API key:', e), + ); +} + export function setDockerAvailable(available: boolean): void { setStore('dockerAvailable', available); }