diff --git a/app/src/main/hl/engines/claude-code/adapter.ts b/app/src/main/hl/engines/claude-code/adapter.ts index 4a36fb63..b9f30d6c 100644 --- a/app/src/main/hl/engines/claude-code/adapter.ts +++ b/app/src/main/hl/engines/claude-code/adapter.ts @@ -17,6 +17,7 @@ import { enrichedEnv, resolveCliSpawn } from '../pathEnrich'; import type { AuthProbe, EngineAdapter, + EngineModelList, InstallProbe, ParseContext, ParseResult, @@ -28,6 +29,41 @@ const ID = 'claude-code'; const DISPLAY = 'Claude Code'; const BIN = 'claude'; +function claudeModelList(): EngineModelList { + const models: EngineModelList['models'] = [ + { + id: 'sonnet', + displayName: 'Sonnet', + description: 'Claude Code Sonnet alias', + source: 'static', + }, + { + id: 'opus', + displayName: 'Opus', + description: 'Claude Code Opus alias', + source: 'static', + }, + { + id: 'haiku', + displayName: 'Haiku', + description: 'Claude Code Haiku alias', + source: 'static', + }, + ]; + + const custom = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION?.trim(); + if (custom && !models.some((m) => m.id === custom)) { + models.push({ + id: custom, + displayName: process.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME?.trim() || custom, + description: process.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION?.trim() || 'Custom Claude Code model', + source: 'env', + }); + } + + return { engineId: ID, models, source: custom ? 'env' : 'static' }; +} + // ── helpers: prompt shaping ───────────────────────────────────────────────── function stringifyToolInput(name: string, input: Record): string { @@ -132,6 +168,10 @@ const claudeCodeAdapter: EngineAdapter = { }); }, + async listModels(): Promise { + return claudeModelList(); + }, + wrapPrompt(ctx: SpawnContext): string { const lines: string[] = [ 'You are driving a specific Chromium browser view on this machine.', @@ -160,6 +200,7 @@ const claudeCodeAdapter: EngineAdapter = { '--verbose', '--dangerously-skip-permissions', ]; + if (_ctx.model) args.push('--model', _ctx.model); if (_ctx.resumeSessionId) args.push('--resume', _ctx.resumeSessionId); args.push(wrappedPrompt); return args; diff --git a/app/src/main/hl/engines/codex/adapter.ts b/app/src/main/hl/engines/codex/adapter.ts index 3cd63b5f..8038b809 100644 --- a/app/src/main/hl/engines/codex/adapter.ts +++ b/app/src/main/hl/engines/codex/adapter.ts @@ -18,7 +18,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { spawn } from 'node:child_process'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { mainLogger } from '../../../logger'; import { register } from '../registry'; import { enrichedEnv, resolveCliSpawn } from '../pathEnrich'; @@ -26,6 +26,7 @@ import { runCodexDeviceLogin } from '../../../identity/codexLogin'; import type { AuthProbe, EngineAdapter, + EngineModelList, InstallProbe, ParseContext, ParseResult, @@ -38,6 +39,167 @@ const ID = 'codex'; const DISPLAY = 'Codex'; const BIN = 'codex'; +const CODEX_FALLBACK_MODELS: EngineModelList['models'] = [ + { + id: 'gpt-5.5', + displayName: 'GPT-5.5', + description: 'Frontier Codex model', + source: 'fallback', + }, + { + id: 'gpt-5.4', + displayName: 'GPT-5.4', + description: 'Strong everyday coding model', + source: 'fallback', + }, + { + id: 'gpt-5.4-mini', + displayName: 'GPT-5.4 Mini', + description: 'Fast, cost-efficient coding model', + source: 'fallback', + }, + { + id: 'gpt-5.3-codex', + displayName: 'GPT-5.3 Codex', + description: 'Codex coding model', + source: 'fallback', + }, +]; + +function normalizeCodexModels(raw: unknown): EngineModelList['models'] { + const data = raw && typeof raw === 'object' ? (raw as { data?: unknown }).data : undefined; + if (!Array.isArray(data)) return []; + return data.flatMap((item): EngineModelList['models'] => { + if (!item || typeof item !== 'object') return []; + const m = item as Record; + const id = typeof m.model === 'string' ? m.model : typeof m.id === 'string' ? m.id : null; + if (!id) return []; + const efforts = Array.isArray(m.supportedReasoningEfforts) + ? m.supportedReasoningEfforts + .map((e) => e && typeof e === 'object' && typeof (e as Record).reasoningEffort === 'string' + ? String((e as Record).reasoningEffort) + : null) + .filter((e): e is string => Boolean(e)) + : undefined; + return [{ + id, + displayName: typeof m.displayName === 'string' ? m.displayName : id, + description: typeof m.description === 'string' ? m.description : undefined, + source: 'app-server', + hidden: typeof m.hidden === 'boolean' ? m.hidden : undefined, + isDefault: typeof m.isDefault === 'boolean' ? m.isDefault : undefined, + supportedReasoningEfforts: efforts && efforts.length > 0 ? efforts : undefined, + }]; + }); +} + +function listCodexModelsViaAppServer(timeoutMs = 10_000): Promise { + return new Promise((resolve) => { + let child: ChildProcessWithoutNullStreams | undefined; + let settled = false; + let stdoutBuf = ''; + let stderrBuf = ''; + const settle = (result: EngineModelList) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { child?.kill('SIGTERM'); } catch { /* already closed */ } + resolve(result); + }; + const fallback = (error: string): EngineModelList => ({ + engineId: ID, + source: 'fallback', + error, + models: CODEX_FALLBACK_MODELS, + }); + const send = (payload: unknown) => { + try { child?.stdin.write(`${JSON.stringify(payload)}\n`); } + catch { /* close handler will return fallback */ } + }; + const handleMessage = (msg: Record) => { + if (msg.id === 1) { + if (msg.error) { + const error = msg.error && typeof msg.error === 'object' && typeof (msg.error as Record).message === 'string' + ? String((msg.error as Record).message) + : 'Codex app-server initialize failed'; + settle(fallback(error)); + return; + } + send({ method: 'initialized' }); + send({ id: 2, method: 'model/list', params: { includeHidden: false } }); + return; + } + if (msg.id === 2) { + if (msg.error) { + const error = msg.error && typeof msg.error === 'object' && typeof (msg.error as Record).message === 'string' + ? String((msg.error as Record).message) + : 'Codex app-server model/list failed'; + settle(fallback(error)); + return; + } + const models = normalizeCodexModels(msg.result); + settle({ + engineId: ID, + source: models.length > 0 ? 'app-server' : 'fallback', + error: models.length > 0 ? undefined : 'Codex app-server returned no models', + models: models.length > 0 ? models : CODEX_FALLBACK_MODELS, + }); + } + }; + + const timer = setTimeout(() => { + settle(fallback('Codex app-server model/list timed out')); + }, timeoutMs); + + try { + const env = enrichedEnv(); + const resolved = resolveCliSpawn(BIN, ['app-server', '--listen', 'stdio://'], { env }); + child = spawn(resolved.command, resolved.args, { stdio: ['pipe', 'pipe', 'pipe'], env, ...resolved.spawnOptions }); + } catch (err) { + settle(fallback((err as Error).message)); + return; + } + + child.stdout.on('data', (d) => { + stdoutBuf += String(d); + let idx; + while ((idx = stdoutBuf.indexOf('\n')) >= 0) { + const line = stdoutBuf.slice(0, idx).trim(); + stdoutBuf = stdoutBuf.slice(idx + 1); + if (!line) continue; + try { + const parsed = JSON.parse(line) as Record; + handleMessage(parsed); + } catch { + // Ignore non-protocol noise defensively. + } + } + }); + child.stderr.on('data', (d) => { + stderrBuf += String(d); + if (stderrBuf.length > 4096) stderrBuf = stderrBuf.slice(-4096); + }); + child.on('spawn', () => { + send({ + id: 1, + method: 'initialize', + params: { + clientInfo: { name: 'browser-use-desktop', title: 'Browser Use', version: '0.0.30' }, + capabilities: { experimentalApi: true }, + }, + }); + }); + child.on('error', (err) => { + settle(fallback(err.message)); + }); + child.on('close', (code) => { + if (!settled) { + settle(fallback(stderrBuf.trim() || `Codex app-server exited before model/list completed (${code})`)); + } + }); + }); +} + function runCli(args: string[], timeoutMs = 5000): Promise<{ ok: boolean; stdout: string; stderr: string }> { return new Promise((resolve) => { let child; @@ -122,6 +284,10 @@ const codexAdapter: EngineAdapter = { return runCodexDeviceLogin(opts); }, + async listModels(): Promise { + return listCodexModelsViaAppServer(); + }, + wrapPrompt(ctx: SpawnContext): string { const lines: string[] = [ 'You are driving a specific Chromium browser view on this machine.', @@ -149,10 +315,11 @@ const codexAdapter: EngineAdapter = { // --yolo skips sandbox + approvals — acceptable because the agent is // already scoped by env BU_TARGET_ID and cwd. Equivalent to Claude Code's // --dangerously-skip-permissions. + const modelArgs = ctx.model ? ['--model', ctx.model] : []; if (ctx.resumeSessionId) { - return ['exec', 'resume', ctx.resumeSessionId, '--json', '--yolo', '-']; + return ['exec', 'resume', ...modelArgs, ctx.resumeSessionId, '--json', '--yolo', '-']; } - return ['exec', '--json', '--yolo', '-']; + return ['exec', ...modelArgs, '--json', '--yolo', '-']; }, getStdinPayload(_ctx: SpawnContext, wrappedPrompt: string): string { diff --git a/app/src/main/hl/engines/cursor-agent/adapter.ts b/app/src/main/hl/engines/cursor-agent/adapter.ts new file mode 100644 index 00000000..7c20a983 --- /dev/null +++ b/app/src/main/hl/engines/cursor-agent/adapter.ts @@ -0,0 +1,409 @@ +/** + * Cursor Agent engine adapter — wraps `agent -p --output-format stream-json`. + * CLI: https://docs.cursor.com/en/cli/overview (binary name: `agent`). + * + * Stream-json shape (similar to Claude Code but distinct): + * system/init → captures session_id (for --resume) and model + * user → echo of the prompt; ignored + * assistant (delta) → message.content[].text — partial chunks carry + * a `timestamp_ms`; emit each as `thinking` + * assistant (final) → same shape but no `timestamp_ms`; ignored to + * avoid duplicating the streamed deltas + * tool_call started → tool_call.ToolCall.args + * tool_call completed → tool_call.ToolCall.result.{success|error} + * result → done / error + usage (no cost field, estimated) + */ + +import { spawn } from 'node:child_process'; +import { mainLogger } from '../../../logger'; +import { register } from '../registry'; +import { enrichedEnv, resolveCliSpawn } from '../pathEnrich'; +import type { + AuthProbe, + EngineAdapter, + EngineModelList, + InstallProbe, + ParseContext, + ParseResult, + SpawnContext, +} from '../types'; +import type { HlEvent } from '../../../../shared/session-schemas'; + +const ID = 'cursor-agent'; +const DISPLAY = 'Cursor Agent'; +const BIN = 'agent'; + +function parseCursorModels(stdout: string): EngineModelList['models'] { + const models: EngineModelList['models'] = []; + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line === 'Available models' || line.startsWith('Tip:')) continue; + const match = line.match(/^(\S+)\s+-\s+(.+)$/); + if (!match) continue; + const id = match[1]; + const rawName = match[2].trim(); + const isDefault = /\(default\)/i.test(rawName); + const isCurrent = /\(current\)/i.test(rawName); + const displayName = rawName.replace(/\s+\((?:default|current)\)/gi, '').trim(); + models.push({ + id, + displayName: displayName || id, + source: 'cli', + isDefault, + isCurrent, + }); + } + return models; +} + +// ── helpers ───────────────────────────────────────────────────────────────── + +function runCli(args: string[], timeoutMs = 5000): Promise<{ ok: boolean; stdout: string; stderr: string }> { + return new Promise((resolve) => { + let child; + try { + const env = enrichedEnv(); + const resolved = resolveCliSpawn(BIN, args, { env }); + child = spawn(resolved.command, resolved.args, { stdio: ['ignore', 'pipe', 'pipe'], env, ...resolved.spawnOptions }); + } + catch { resolve({ ok: false, stdout: '', stderr: 'spawn failed' }); return; } + let stdout = ''; let stderr = ''; + child.stdout.on('data', (d) => (stdout += String(d))); + child.stderr.on('data', (d) => (stderr += String(d))); + const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs); + child.on('error', () => { clearTimeout(timer); resolve({ ok: false, stdout, stderr }); }); + child.on('close', (code) => { clearTimeout(timer); resolve({ ok: code === 0, stdout, stderr }); }); + }); +} + +/** Map cursor's `ToolCall` wrapper key to a Claude-Code-style tool name + * the rest of the UI/postprocessor already understands. Unknown wrappers fall + * through with the `ToolCall` suffix stripped. */ +function wrapperToToolName(wrapperKey: string): string { + if (wrapperKey === 'shellToolCall') return 'Bash'; + if (wrapperKey === 'readToolCall') return 'Read'; + if (wrapperKey === 'writeToolCall') return 'Write'; + if (wrapperKey === 'editToolCall' || wrapperKey === 'patchToolCall') return 'Edit'; + if (wrapperKey === 'lsToolCall') return 'LS'; + if (wrapperKey === 'globToolCall') return 'Glob'; + if (wrapperKey === 'grepToolCall') return 'Grep'; + if (wrapperKey === 'webSearchToolCall') return 'WebSearch'; + if (wrapperKey === 'webFetchToolCall') return 'WebFetch'; + // Fallback: drop trailing "ToolCall", capitalize first letter so it renders + // nicely in the agent pane (e.g. `taskToolCall` → `Task`). + const stripped = wrapperKey.replace(/ToolCall$/, ''); + return stripped ? stripped.charAt(0).toUpperCase() + stripped.slice(1) : wrapperKey; +} + +/** Pull a meaningful args object out of cursor's wrapped tool_call payload. + * Different tools nest things differently — shell puts the command at + * `args.command`, read at `args.path`, etc. Forward the raw `args` plus a + * `preview` string so the renderer can show something inline. */ +function extractToolArgs(name: string, raw: Record | undefined): Record { + const args = (raw?.args as Record | undefined) ?? {}; + const out: Record = { ...args }; + let preview: string; + if (name === 'Bash' && typeof args.command === 'string') { + preview = args.command as string; + } else if (typeof args.path === 'string') { + preview = args.path as string; + } else if (typeof args.file_path === 'string') { + preview = args.file_path as string; + } else { + preview = JSON.stringify(args); + } + out.preview = preview; + // Surface `path`/`file_path` interchangeably so the harness post-processor + // (which detects helpers.js / AGENTS.md edits) catches cursor reads/writes. + if (typeof args.path === 'string' && typeof out.file_path !== 'string') { + out.file_path = args.path; + } + return out; +} + +function extractToolResult(raw: Record | undefined): { text: string; ok: boolean } { + const result = raw?.result as Record | undefined; + if (!result) return { text: '', ok: true }; + if (result.error) { + const err = result.error as Record | string; + if (typeof err === 'string') return { text: err, ok: false }; + return { text: JSON.stringify(err), ok: false }; + } + const success = result.success as Record | undefined; + if (!success) return { text: JSON.stringify(result), ok: true }; + // Shell tools: prefer stdout, fall back to stderr. + if (typeof success.stdout === 'string' || typeof success.stderr === 'string') { + const stdout = (success.stdout as string | undefined) ?? ''; + const stderr = (success.stderr as string | undefined) ?? ''; + const exitCode = typeof success.exitCode === 'number' ? (success.exitCode as number) : 0; + return { text: stdout || stderr, ok: exitCode === 0 }; + } + // File tools: `content` for read, `path`/`bytesWritten` for write. + if (typeof success.content === 'string') return { text: success.content as string, ok: true }; + return { text: JSON.stringify(success), ok: true }; +} + +// ── adapter ───────────────────────────────────────────────────────────────── + +const cursorAgentAdapter: EngineAdapter = { + id: ID, + displayName: DISPLAY, + binaryName: BIN, + + async probeInstalled(): Promise { + const r = await runCli(['--version']); + if (!r.ok) return { installed: false, error: r.stderr || 'agent not found on PATH' }; + const m = r.stdout.match(/(\d{4}\.\d{2}\.\d{2}[\w.-]*)/) ?? r.stdout.match(/(\d+\.\d+\.\d+)/); + return { installed: true, version: m?.[1] }; + }, + + async probeAuthed(): Promise { + // `agent status` exits 0 in both states; discriminate on output text. + const r = await runCli(['status']); + const text = `${r.stdout}\n${r.stderr}`; + if (/logged in as/i.test(text)) return { authed: true }; + if (/not logged in/i.test(text)) return { authed: false, error: 'not logged in' }; + if (!r.ok) return { authed: false, error: r.stderr || r.stdout || 'agent status failed' }; + return { authed: false, error: 'unknown auth state' }; + }, + + async openLoginInTerminal(): Promise<{ opened: boolean; error?: string }> { + // `agent login` opens the system browser to the OAuth flow and waits for + // the callback. We spawn with stdio pipes so the child stays alive after + // this Promise resolves; the EnginePicker polls probeAuthed() to detect + // completion. + return new Promise((resolve) => { + let child; + try { + const env = enrichedEnv(); + const resolved = resolveCliSpawn(BIN, ['login'], { env }); + child = spawn(resolved.command, resolved.args, { stdio: ['ignore', 'pipe', 'pipe'], env, ...resolved.spawnOptions }); + } catch (err) { + resolve({ opened: false, error: (err as Error).message }); + return; + } + let stderrBuf = ''; + let stdoutBuf = ''; + let settled = false; + const finish = (result: { opened: boolean; error?: string }) => { + if (settled) return; + settled = true; + resolve(result); + }; + const timer = setTimeout(() => { + mainLogger.warn('cursor-agent.login.timeout'); + try { child.kill('SIGTERM'); } catch { /* already closed */ } + }, 5 * 60 * 1000); + + child.stdout.on('data', (d) => { stdoutBuf += String(d); if (stdoutBuf.length > 4096) stdoutBuf = stdoutBuf.slice(-4096); }); + child.stderr.on('data', (d) => { stderrBuf += String(d); if (stderrBuf.length > 4096) stderrBuf = stderrBuf.slice(-4096); }); + child.on('spawn', () => { + mainLogger.info('cursor-agent.login.spawn'); + finish({ opened: true }); + }); + child.on('error', (err) => { + clearTimeout(timer); + mainLogger.warn('cursor-agent.login.error', { error: err.message }); + finish({ opened: false, error: err.message }); + }); + child.on('close', (code) => { + clearTimeout(timer); + mainLogger.info('cursor-agent.login.close', { code, stderr: stderrBuf.slice(-400) }); + if (code !== 0 && !settled) { + finish({ opened: false, error: stderrBuf.trim() || stdoutBuf.trim() || `agent login exit ${code}` }); + } + }); + }); + }, + + async listModels(): Promise { + const r = await runCli(['--list-models'], 10_000); + if (!r.ok) { + return { + engineId: ID, + source: 'fallback', + error: r.stderr || r.stdout || 'Unable to list Cursor Agent models', + models: [ + { id: 'auto', displayName: 'Auto', source: 'fallback' }, + { id: 'composer-2-fast', displayName: 'Composer 2 Fast', source: 'fallback' }, + { id: 'composer-2', displayName: 'Composer 2', source: 'fallback' }, + ], + }; + } + const models = parseCursorModels(r.stdout); + return { + engineId: ID, + source: 'cli', + models, + }; + }, + + wrapPrompt(ctx: SpawnContext): string { + const lines: string[] = [ + 'You are driving a specific Chromium browser view on this machine.', + `Your target is CDP target_id=${ctx.targetId} on port ${ctx.cdpPort} (env BU_TARGET_ID / BU_CDP_PORT).`, + 'Read `./AGENTS.md` for how to drive the browser in this harness.', + 'Always read `./helpers.js` before writing scripts — that is where the functions live. Edit it if a helper is missing.', + ]; + if (ctx.attachmentRefs.length > 0) { + lines.push('', 'The user attached these files for this task. Read each with your Read tool before acting:'); + for (const a of ctx.attachmentRefs) lines.push(` - ${a.relPath} (${a.mime}, ${a.size} bytes)`); + } + lines.push( + '', + `When the user asks you to produce a file (a report, CSV, screenshot, transcript, etc.), save it to \`./outputs/${ctx.sessionId}/\`. Mention the filename in your final answer.`, + '', + `Task: ${ctx.prompt}`, + ); + return lines.join('\n'); + }, + + buildSpawnArgs(ctx: SpawnContext, wrappedPrompt: string): string[] { + // --print: headless mode; --output-format stream-json: NDJSON we parse; + // --stream-partial-output: emit text deltas as separate events so the UI + // streams thinking instead of dumping the final message at the end; + // --force / --yolo: skip approvals (browser is already scoped by env); + // --trust: trust the harness cwd without prompting (only valid with -p); + // --sandbox disabled: helpers.js makes outbound CDP/network calls; the + // default sandbox can break those in headless mode. + const args: string[] = [ + '-p', + '--output-format', 'stream-json', + '--stream-partial-output', + '--force', + '--trust', + '--sandbox', 'disabled', + ]; + if (ctx.model) args.push('--model', ctx.model); + if (ctx.resumeSessionId) args.push('--resume', ctx.resumeSessionId); + args.push(wrappedPrompt); + return args; + }, + + buildEnv(ctx: SpawnContext, baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const env = enrichedEnv(baseEnv); + // Strip any pre-existing CURSOR_API_KEY so OAuth (`agent login`) wins by + // default. If the user saved an explicit API key in the app, inject it. + delete env.CURSOR_API_KEY; + if (ctx.savedApiKey) env.CURSOR_API_KEY = ctx.savedApiKey; + env.BU_TARGET_ID = ctx.targetId; + env.BU_CDP_PORT = String(ctx.cdpPort); + return env; + }, + + parseLine(line: string, ctx: ParseContext): ParseResult { + let evt: unknown; + try { evt = JSON.parse(line); } catch { return { events: [] }; } + if (!evt || typeof evt !== 'object') return { events: [] }; + const e = evt as Record; + const type = e.type as string | undefined; + const events: HlEvent[] = []; + let capturedSessionId: string | undefined; + let terminalDone = false; + let terminalError: string | undefined; + + if (type === 'system') { + const subtype = e.subtype as string | undefined; + if (subtype === 'init') { + mainLogger.info('cursor-agent.init', { model: e.model, session_id: e.session_id, apiKeySource: e.apiKeySource }); + if (typeof e.session_id === 'string') capturedSessionId = e.session_id; + if (typeof e.model === 'string') ctx.currentModel = e.model; + } + return { events, capturedSessionId }; + } + + if (type === 'user') { + // Echo of the user's own prompt — nothing to surface. + return { events }; + } + + if (type === 'assistant') { + // Cursor emits two flavors of assistant message: + // - streamed deltas (have `timestamp_ms`) — small text fragments that + // should each become a `thinking` event so the UI streams them. + // - one final consolidated message (no `timestamp_ms`) — duplicates + // the concatenated deltas; skip to avoid double-rendering. + const isDelta = typeof e.timestamp_ms === 'number'; + if (!isDelta) return { events }; + const msg = e.message as Record | undefined; + const content = msg?.content as Array> | undefined; + if (!Array.isArray(content)) return { events }; + for (const block of content) { + if (block?.type !== 'text') continue; + const txt = typeof block.text === 'string' ? (block.text as string) : ''; + if (txt.length > 0) { + events.push({ type: 'thinking', text: txt }); + if (txt.trim()) ctx.lastNarrative = txt; + } + } + return { events }; + } + + if (type === 'tool_call') { + const subtype = e.subtype as string | undefined; + const callId = e.call_id as string | undefined; + const wrapper = e.tool_call as Record | undefined; + if (!callId || !wrapper) return { events }; + const wrapperKeys = Object.keys(wrapper); + const wrapperKey = wrapperKeys[0]; + if (!wrapperKey) return { events }; + const inner = wrapper[wrapperKey] as Record | undefined; + const name = wrapperToToolName(wrapperKey); + + if (subtype === 'started') { + ctx.iter++; + const args = extractToolArgs(name, inner); + ctx.pendingTools.set(callId, { name, startedAt: Date.now(), iter: ctx.iter }); + events.push({ type: 'tool_call', name, args, iteration: ctx.iter }); + return { events }; + } + if (subtype === 'completed') { + const match = ctx.pendingTools.get(callId); + const { text, ok } = extractToolResult(inner); + const ms = match ? Date.now() - match.startedAt : 0; + const resolvedName = match?.name ?? name; + events.push({ type: 'tool_result', name: resolvedName, ok, preview: text.slice(0, 2000), ms }); + ctx.pendingTools.delete(callId); + return { events }; + } + return { events }; + } + + if (type === 'result') { + // Cursor's result has `usage` but no cost field — surface the tokens + // with cost=0 so the session totals at least reflect token consumption. + const usage = e.usage as Record | undefined; + if (usage) { + const inputTokens = typeof usage.inputTokens === 'number' ? (usage.inputTokens as number) : 0; + const outputTokens = typeof usage.outputTokens === 'number' ? (usage.outputTokens as number) : 0; + const cacheRead = typeof usage.cacheReadTokens === 'number' ? (usage.cacheReadTokens as number) : 0; + const cacheWrite = typeof usage.cacheWriteTokens === 'number' ? (usage.cacheWriteTokens as number) : 0; + events.push({ + type: 'turn_usage', + inputTokens, + outputTokens, + cachedInputTokens: cacheRead + cacheWrite, + costUsd: 0, + model: ctx.currentModel, + source: 'estimated', + }); + mainLogger.info('cursor-agent.turnUsage', { inputTokens, outputTokens, cacheRead, cacheWrite, model: ctx.currentModel }); + } + + const isError = e.is_error === true; + const subtype = e.subtype as string | undefined; + const resultText = (e.result as string | undefined) ?? ''; + if (isError || (subtype && subtype !== 'success')) { + terminalError = `cursor_agent_error: ${subtype ?? 'error'} ${resultText}`.trim(); + events.push({ type: 'error', message: terminalError }); + } else { + terminalDone = true; + events.push({ type: 'done', summary: resultText || ctx.lastNarrative || '(done)', iterations: ctx.iter }); + } + } + + return { events, capturedSessionId, terminalDone, terminalError }; + }, +}; + +register(cursorAgentAdapter); diff --git a/app/src/main/hl/engines/index.ts b/app/src/main/hl/engines/index.ts index 9fd7441d..5e90cf9c 100644 --- a/app/src/main/hl/engines/index.ts +++ b/app/src/main/hl/engines/index.ts @@ -6,6 +6,7 @@ // Adapters (side-effect register()): import './claude-code/adapter'; import './codex/adapter'; +import './cursor-agent/adapter'; export { runEngine } from './runEngine'; export { get as getAdapter, list as listAdapters, DEFAULT_ENGINE_ID } from './registry'; diff --git a/app/src/main/hl/engines/runEngine.ts b/app/src/main/hl/engines/runEngine.ts index a0037f8d..517aae75 100644 --- a/app/src/main/hl/engines/runEngine.ts +++ b/app/src/main/hl/engines/runEngine.ts @@ -180,6 +180,7 @@ export async function runEngine(opts: RunEngineOptions): Promise { cdpPort: opts.cdpPort, resumeSessionId: opts.resumeSessionId, savedApiKey, + model: opts.model, attachmentRefs, }; const wrappedPrompt = adapter.wrapPrompt(spawnCtx); @@ -193,6 +194,7 @@ export async function runEngine(opts: RunEngineOptions): Promise { targetId, cdpPort: opts.cdpPort, hasResume: !!opts.resumeSessionId, + model: opts.model ?? null, attachmentCount: attachmentRefs.length, authSource: savedApiKey ? 'savedApiKey' : 'cliManaged', args: args.map((a) => (a.length > 120 ? `${a.slice(0, 100)}…<${a.length}ch>` : a)), diff --git a/app/src/main/hl/engines/types.ts b/app/src/main/hl/engines/types.ts index 7028de8d..4bc270c6 100644 --- a/app/src/main/hl/engines/types.ts +++ b/app/src/main/hl/engines/types.ts @@ -24,6 +24,8 @@ export interface SpawnContext { resumeSessionId?: string; /** Optional user-supplied API key; adapter decides how to inject. */ savedApiKey?: string; + /** Optional model id selected by the user. Undefined means use the CLI default. */ + model?: string; /** List of attachment paths (relative to harnessDir) the adapter may mention in wrappedPrompt. */ attachmentRefs: Array<{ relPath: string; mime: string; size: number }>; } @@ -92,6 +94,8 @@ export interface EngineAdapter { * can't use the default localhost-callback OAuth. Callers should poll * `probeAuthed()` to detect when auth.json / OAuth creds appear. */ openLoginInTerminal(opts?: { deviceAuth?: boolean }): Promise<{ opened: boolean; error?: string; verificationUrl?: string; deviceCode?: string }>; + /** List selectable models when the engine supports it. Static fallbacks are OK. */ + listModels?(): Promise; // Execution /** Produce the argv for spawning this engine in headless mode. */ @@ -118,6 +122,8 @@ export interface EngineAdapter { export interface RunEngineOptions { engineId: string; prompt: string; + /** Optional model id selected by the user. Undefined means use the CLI default. */ + model?: string; sessionId: string; webContents: WebContents; cdpPort: number; @@ -131,3 +137,24 @@ export interface RunEngineOptions { * can stamp the session with the mode that actually ran it. */ onAuthResolved?: (info: { authMode: 'apiKey' | 'subscription' | null; subscriptionType: string | null }) => void; } + +export interface EngineModelInfo { + id: string; + displayName: string; + description?: string; + source: 'cli' | 'app-server' | 'static' | 'env' | 'fallback'; + isDefault?: boolean; + isCurrent?: boolean; + hidden?: boolean; + supportedReasoningEfforts?: string[]; +} + +export interface EngineModelList { + engineId: string; + models: EngineModelInfo[]; + source: EngineModelInfo['source']; + error?: string; + cached?: boolean; + cachedAt?: number; + expiresAt?: number; +} diff --git a/app/src/main/index.ts b/app/src/main/index.ts index 6bdd635d..5f64bf20 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -100,6 +100,7 @@ import { assertString, assertAttachments } from './ipc-validators'; // pluggable (claude-code, codex, …) — see src/main/hl/engines/. import { bootstrapHarness, harnessDir } from './hl/harness'; import { runEngine, DEFAULT_ENGINE_ID } from './hl/engines'; +import type { EngineModelList } from './hl/engines/types'; import { getEngine, setEngine, type EngineId } from './hl/engine'; import { forwardAgentEvent } from './pill'; // Session management @@ -123,6 +124,74 @@ import { stopUpdater, } from './updater'; +const ENGINE_MODEL_CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +type CachedEngineModelList = EngineModelList & { + cachedAt: number; + expiresAt: number; +}; + +interface EngineModelCacheFile { + version: 1; + entries: Record; +} + +let engineModelCache: EngineModelCacheFile | null = null; +const engineModelRequests = new Map>(); + +function engineModelCachePath(): string { + return path.join(app.getPath('userData'), 'engine-model-cache.json'); +} + +function readEngineModelCache(): EngineModelCacheFile { + if (engineModelCache) return engineModelCache; + try { + const raw = fs.readFileSync(engineModelCachePath(), 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + if (parsed.version === 1 && parsed.entries && typeof parsed.entries === 'object') { + engineModelCache = { version: 1, entries: parsed.entries as Record }; + return engineModelCache; + } + } catch { + // Missing or corrupt cache is non-fatal; model listing can repopulate it. + } + engineModelCache = { version: 1, entries: {} }; + return engineModelCache; +} + +function writeEngineModelCache(cache: EngineModelCacheFile): void { + engineModelCache = cache; + try { + fs.mkdirSync(path.dirname(engineModelCachePath()), { recursive: true }); + fs.writeFileSync(engineModelCachePath(), JSON.stringify(cache, null, 2)); + } catch (err) { + mainLogger.warn('engineModelCache.writeFailed', { error: (err as Error).message }); + } +} + +function getCachedEngineModels(engineId: string): CachedEngineModelList | null { + const entry = readEngineModelCache().entries[engineId]; + if (!entry) return null; + if (Date.now() >= entry.expiresAt) return null; + return entry; +} + +function storeEngineModels(engineId: string, list: EngineModelList): EngineModelList { + const now = Date.now(); + const stamped: CachedEngineModelList = { + ...list, + cached: false, + cachedAt: now, + expiresAt: now + ENGINE_MODEL_CACHE_TTL_MS, + }; + if (list.models.length > 0 && list.source !== 'fallback' && !list.error) { + const cache = readEngineModelCache(); + cache.entries[engineId] = stamped; + writeEngineModelCache(cache); + } + return stamped; +} + // --------------------------------------------------------------------------- // Crash telemetry: catch unhandled errors before anything else // --------------------------------------------------------------------------- @@ -376,19 +445,25 @@ app.whenReady().then(async () => { ipcMain.handle('pill:submit', async (_event, payload: unknown) => { let promptRaw: unknown; let attachmentsRaw: unknown; + let modelRaw: unknown; if (typeof payload === 'string') { promptRaw = payload; } else if (payload && typeof payload === 'object') { promptRaw = (payload as { prompt?: unknown }).prompt; attachmentsRaw = (payload as { attachments?: unknown }).attachments; + modelRaw = (payload as { model?: unknown }).model; } else { - throw new Error('pill:submit payload must be a string or { prompt, attachments? }'); + throw new Error('pill:submit payload must be a string or { prompt, attachments?, engine?, model? }'); } const validatedPrompt = assertString(promptRaw, 'prompt', 10000); const attachments = assertAttachments(attachmentsRaw); + const pillModelId = modelRaw == null || modelRaw === '' + ? null + : assertString(modelRaw, 'model', 200); mainLogger.info('main.pill:submit', { promptLength: validatedPrompt.length, attachmentCount: attachments.length, + model: pillModelId, }); hidePill(); @@ -405,6 +480,7 @@ app.whenReady().then(async () => { ? pillEngineRaw : DEFAULT_ENGINE_ID; sessionManager.setSessionEngine(id, pillEngineId); + sessionManager.setSessionModel(id, pillModelId); if (attachments.length > 0) { const turnIndex = sessionManager.getNextAttachmentTurnIndex(id); for (const a of attachments) { @@ -414,6 +490,7 @@ app.whenReady().then(async () => { captureEvent('session_created', { source: 'pill', engine: pillEngineId, + model: pillModelId ?? 'default', prompt_length: validatedPrompt.length, attachments_count: attachments.length, }); @@ -697,6 +774,7 @@ app.whenReady().then(async () => { launched = true; runEngine({ engineId, + model: sessionManager.getSessionModel(id) ?? undefined, harnessDir: harnessDir(), sessionId: id, prompt: sessionManager.getSession(id)!.prompt, @@ -746,26 +824,31 @@ app.whenReady().then(async () => { let promptRaw: unknown; let attachmentsRaw: unknown; let engineRaw: unknown; + let modelRaw: unknown; if (typeof payload === 'string') { promptRaw = payload; } else if (payload && typeof payload === 'object') { promptRaw = (payload as { prompt?: unknown }).prompt; attachmentsRaw = (payload as { attachments?: unknown }).attachments; engineRaw = (payload as { engine?: unknown }).engine; + modelRaw = (payload as { model?: unknown }).model; } else { - throw new Error('sessions:create payload must be a string or { prompt, attachments?, engine? }'); + throw new Error('sessions:create payload must be a string or { prompt, attachments?, engine?, model? }'); } const validatedPrompt = assertString(promptRaw, 'prompt', 10000); const attachments = assertAttachments(attachmentsRaw); const engineId = engineRaw == null ? DEFAULT_ENGINE_ID : assertString(engineRaw, 'engine', 50); + const modelId = modelRaw == null || modelRaw === '' ? null : assertString(modelRaw, 'model', 200); mainLogger.info('main.sessions:create', { promptLength: validatedPrompt.length, attachmentCount: attachments.length, engineId, + model: modelId, attachmentMeta: attachments.map((a) => ({ name: a.name, mime: a.mime, size: a.bytes.byteLength })), }); const id = sessionManager.createSession(validatedPrompt); sessionManager.setSessionEngine(id, engineId); + sessionManager.setSessionModel(id, modelId); if (attachments.length > 0) { const turnIndex = sessionManager.getNextAttachmentTurnIndex(id); for (const a of attachments) { @@ -775,6 +858,7 @@ app.whenReady().then(async () => { captureEvent('session_created', { source: 'hub', engine: engineId, + model: modelId ?? 'default', prompt_length: validatedPrompt.length, attachments_count: attachments.length, }); @@ -824,6 +908,7 @@ app.whenReady().then(async () => { steerQueues.set(validatedId, []); runEngine({ engineId: sessionManager.getSessionEngine(validatedId) ?? DEFAULT_ENGINE_ID, + model: sessionManager.getSessionModel(validatedId) ?? undefined, harnessDir: harnessDir(), sessionId: validatedId, prompt: validatedPrompt, @@ -897,6 +982,7 @@ app.whenReady().then(async () => { steerQueues.set(validatedId, []); runEngine({ engineId: sessionManager.getSessionEngine(validatedId) ?? DEFAULT_ENGINE_ID, + model: sessionManager.getSessionModel(validatedId) ?? undefined, harnessDir: harnessDir(), sessionId: validatedId, prompt: session.prompt, @@ -1025,6 +1111,43 @@ app.whenReady().then(async () => { return adapter.openLoginInTerminal(opts); }); + ipcMain.handle('sessions:list-engine-models', async (_event, engineId: string, opts?: { forceRefresh?: boolean }) => { + const validated = assertString(engineId, 'engineId', 50); + const forceRefresh = Boolean(opts?.forceRefresh); + if (!forceRefresh) { + const cached = getCachedEngineModels(validated); + if (cached) { + return { ...cached, cached: true }; + } + const inFlight = engineModelRequests.get(validated); + if (inFlight) return inFlight; + } + + const request = (async (): Promise => { + const { getAdapter } = await import('./hl/engines'); + const adapter = getAdapter(validated); + if (!adapter) throw new Error(`unknown engine: ${validated}`); + if (!adapter.listModels) { + return storeEngineModels(adapter.id, { engineId: adapter.id, models: [], source: 'static' }); + } + const listed = await adapter.listModels(); + if (listed.source === 'fallback' || listed.error) { + const stale = readEngineModelCache().entries[validated]; + if (stale && !forceRefresh) { + return { ...stale, cached: true, error: listed.error }; + } + } + return storeEngineModels(adapter.id, listed); + })(); + + if (!forceRefresh) engineModelRequests.set(validated, request); + try { + return await request; + } finally { + engineModelRequests.delete(validated); + } + }); + ipcMain.handle('sessions:reveal-output', async (_event, filePath: string) => { const validated = assertString(filePath, 'filePath', 2000); const resolvedPath = path.isAbsolute(validated) diff --git a/app/src/main/sessions/SessionDb.ts b/app/src/main/sessions/SessionDb.ts index 2dcc7348..1c05b75c 100644 --- a/app/src/main/sessions/SessionDb.ts +++ b/app/src/main/sessions/SessionDb.ts @@ -15,6 +15,7 @@ interface SessionRow { origin_conversation_id: string | null; primary_site: string | null; engine: string | null; + model: string | null; auth_mode: string | null; subscription_type: string | null; cost_usd: number | null; @@ -34,6 +35,7 @@ export class SessionDb { updateCreatedAt: Database.Statement; updatePrimarySite: Database.Statement; updateEngine: Database.Statement; + updateModel: Database.Statement; updateAuth: Database.Statement; updateUsage: Database.Statement; getSession: Database.Statement; @@ -101,6 +103,9 @@ export class SessionDb { updateEngine: this.db.prepare( 'UPDATE sessions SET engine = ?, updated_at = ? WHERE id = ?' ), + updateModel: this.db.prepare( + 'UPDATE sessions SET model = ?, updated_at = ? WHERE id = ?' + ), updateAuth: this.db.prepare( 'UPDATE sessions SET auth_mode = ?, subscription_type = ?, updated_at = ? WHERE id = ?' ), @@ -333,6 +338,18 @@ export class SessionDb { mainLogger.info('SessionDb.migration.complete', { version: 10 }); } + if (this.getVersion() < 11) { + mainLogger.info('SessionDb.migration.running', { from: this.getVersion(), to: 11 }); + this.db.transaction(() => { + const cols = this.db.pragma('table_info(sessions)') as Array<{ name: string }>; + if (!cols.some((c) => c.name === 'model')) { + this.db.exec('ALTER TABLE sessions ADD COLUMN model TEXT'); + } + this.setVersion(11); + })(); + mainLogger.info('SessionDb.migration.complete', { version: 11 }); + } + const final = this.getVersion(); if (final !== DB_SCHEMA_VERSION) { const msg = `SessionDb migration did not reach expected version. Got ${final}, expected ${DB_SCHEMA_VERSION}.`; @@ -417,6 +434,20 @@ export class SessionDb { } } + updateModel(id: string, model: string | null): void { + if (this.closed) return; + const now = Date.now(); + try { + const result = this.stmts.updateModel.run(model, now, id); + if (result.changes === 0) { + mainLogger.warn('SessionDb.updateModel.notFound', { id, model }); + } + } catch (err) { + mainLogger.error('SessionDb.updateModel.failed', { id, model, error: (err as Error).message }); + throw err; + } + } + updateUsage(id: string, usage: { costUsd: number; inputTokens: number; outputTokens: number; cachedInputTokens: number; costSource: 'exact' | 'estimated' }): void { if (this.closed) return; const now = Date.now(); diff --git a/app/src/main/sessions/SessionManager.ts b/app/src/main/sessions/SessionManager.ts index 11a1c2e3..44334644 100644 --- a/app/src/main/sessions/SessionManager.ts +++ b/app/src/main/sessions/SessionManager.ts @@ -26,11 +26,8 @@ export class SessionManager extends EventEmitter { * In-memory only — cleared on process restart and on rerun. */ private claudeSessionIds: Map = new Map(); - /** - * Per-session engine id chosen at create time. In-memory only; reverts to - * default on process restart until a DB column is added in migration v8. - */ private sessionEngines: Map = new Map(); + private sessionModels: Map = new Map(); private termStates: Map = new Map(); private db: SessionDb; @@ -71,6 +68,10 @@ export class SessionManager extends EventEmitter { (session as AgentSession & { engine?: string }).engine = row.engine; this.sessionEngines.set(row.id, row.engine); } + if (row.model) { + (session as AgentSession & { model?: string }).model = row.model; + this.sessionModels.set(row.id, row.model); + } if (row.auth_mode === 'apiKey' || row.auth_mode === 'subscription') { session.authMode = row.auth_mode; } @@ -373,6 +374,8 @@ export class SessionManager extends EventEmitter { this.clearStuckTimer(id); this.abortControllers.delete(id); this.sessions.delete(id); + this.sessionEngines.delete(id); + this.sessionModels.delete(id); this.termStates.delete(id); this.db.deleteSession(id); mainLogger.info('SessionManager.deleteSession', { id }); @@ -485,7 +488,7 @@ export class SessionManager extends EventEmitter { return this.claudeSessionIds.get(id); } - /** Record the engine id chosen for this session (in-memory only). Also + /** Record the engine id chosen for this session. Also * stamps `session.engine` so every future `{ ...session }` snapshot carries * the provider id to the renderer for header icon rendering. */ setSessionEngine(id: string, engineId: string): void { @@ -503,6 +506,24 @@ export class SessionManager extends EventEmitter { return this.sessionEngines.get(id) ?? null; } + /** Record the explicit model selected for this session. Null means CLI default. */ + setSessionModel(id: string, model: string | null): void { + const session = this.sessions.get(id); + if (model) this.sessionModels.set(id, model); + else this.sessionModels.delete(id); + this.db.updateModel(id, model); + if (session) { + if (model) (session as AgentSession & { model?: string }).model = model; + else delete (session as AgentSession & { model?: string }).model; + this.emitEvent('session-updated', { ...session }); + } + } + + /** Retrieve the per-session explicit model, or null for CLI default. */ + getSessionModel(id: string): string | null { + return this.sessionModels.get(id) ?? null; + } + /** Snapshot the auth mode + subscription type that actually ran this session. * Called once at spawn by runEngine via the onAuthResolved callback. Frozen * for the life of the session — later global auth-mode changes do not diff --git a/app/src/main/sessions/db-constants.ts b/app/src/main/sessions/db-constants.ts index df6ad1ac..76a4a4c4 100644 --- a/app/src/main/sessions/db-constants.ts +++ b/app/src/main/sessions/db-constants.ts @@ -1,4 +1,4 @@ -export const DB_SCHEMA_VERSION = 10; +export const DB_SCHEMA_VERSION = 11; // Hard cap on attachments per session to prevent DB bloat from runaway // follow-up uploads. Enforced in SessionDb.saveAttachment. diff --git a/app/src/main/settings/apiKeyIpc.ts b/app/src/main/settings/apiKeyIpc.ts index 3351c48e..04e298d2 100644 --- a/app/src/main/settings/apiKeyIpc.ts +++ b/app/src/main/settings/apiKeyIpc.ts @@ -38,6 +38,7 @@ const CH_OAI_SAVE = 'settings:openai-key:save'; const CH_OAI_TEST = 'settings:openai-key:test'; const CH_OAI_DELETE = 'settings:openai-key:delete'; const CH_CODEX_LOGOUT = 'settings:codex:logout'; +const CH_CURSOR_LOGOUT = 'settings:cursor-agent:logout'; const CH_CC_LOGIN = 'settings:claude-code:login'; const CH_CC_LOGOUT = 'settings:claude-code:logout'; @@ -290,6 +291,11 @@ async function handleCodexLogout(): Promise<{ opened: boolean; error?: string }> return runLogoutCommand('codex', ['logout']); } +async function handleCursorLogout(): Promise<{ opened: boolean; error?: string }> { + mainLogger.info('apiKeyIpc.cursor.logout'); + return runLogoutCommand('agent', ['logout']); +} + async function handleClaudeCodeLogout(): Promise<{ opened: boolean; error?: string }> { mainLogger.info('apiKeyIpc.claudeCode.logout'); // Clear our keychain mirror first so the UI updates immediately; then @@ -313,6 +319,7 @@ export function registerApiKeyHandlers(): void { ipcMain.handle(CH_OAI_TEST, handleOpenAiTest); ipcMain.handle(CH_OAI_DELETE, handleOpenAiDelete); ipcMain.handle(CH_CODEX_LOGOUT, handleCodexLogout); + ipcMain.handle(CH_CURSOR_LOGOUT, handleCursorLogout); ipcMain.handle(CH_CC_LOGIN, handleClaudeCodeLogin); ipcMain.handle(CH_CC_LOGOUT, handleClaudeCodeLogout); mainLogger.info('apiKeyIpc.register.ok'); diff --git a/app/src/preload/pill.ts b/app/src/preload/pill.ts index 46e76603..d78216cc 100644 --- a/app/src/preload/pill.ts +++ b/app/src/preload/pill.ts @@ -59,14 +59,16 @@ contextBridge.exposeInMainWorld('pillAPI', { prompt: string, attachments?: Array<{ name: string; mime: string; bytes: Uint8Array }>, engine?: string, + model?: string, ): Promise<{ task_id: string }> => { log.info('preload.pill.submit', { message: 'Invoking pill:submit', promptLength: prompt.length, attachmentCount: attachments?.length ?? 0, engine: engine ?? '(default)', + model: model ?? '(default)', }); - return ipcRenderer.invoke('pill:submit', { prompt, attachments, engine }); + return ipcRenderer.invoke('pill:submit', { prompt, attachments, engine, model }); }, selectSession: (id: string): void => { @@ -242,7 +244,7 @@ contextBridge.exposeInMainWorld('pillAPI', { // Minimal `electronAPI.sessions` subset so shared components (EnginePicker) // used inside the pill renderer can reach the same engine IPCs the hub uses. -// Only the three calls EnginePicker needs — don't grow this without a reason. +// Only the calls EnginePicker needs — don't grow this without a reason. contextBridge.exposeInMainWorld('electronAPI', { shell: { platform: process.platform, @@ -251,6 +253,15 @@ contextBridge.exposeInMainWorld('electronAPI', { sessions: { listEngines: (): Promise> => ipcRenderer.invoke('sessions:list-engines'), + listEngineModels: (engineId: string, opts?: { forceRefresh?: boolean }): Promise<{ + engineId: string; + models: Array<{ id: string; displayName: string; description?: string; source: string; isDefault?: boolean; isCurrent?: boolean; hidden?: boolean; supportedReasoningEfforts?: string[] }>; + source: string; + error?: string; + cached?: boolean; + cachedAt?: number; + expiresAt?: number; + }> => ipcRenderer.invoke('sessions:list-engine-models', engineId, opts), engineStatus: (engineId: string): Promise<{ id: string; displayName: string; diff --git a/app/src/preload/shell.ts b/app/src/preload/shell.ts index 0111d4a5..e6f29a9c 100644 --- a/app/src/preload/shell.ts +++ b/app/src/preload/shell.ts @@ -91,6 +91,18 @@ contextBridge.exposeInMainWorld('electronAPI', { logout: (): Promise<{ opened: boolean; error?: string }> => ipcRenderer.invoke('settings:codex:logout'), }, + cursor: { + status: (): Promise<{ + id: string; + displayName: string; + installed: { installed: boolean; version?: string; error?: string }; + authed: { authed: boolean; error?: string }; + }> => ipcRenderer.invoke('sessions:engine-status', 'cursor-agent'), + login: (): Promise<{ opened: boolean; error?: string }> => + ipcRenderer.invoke('sessions:engine-login', 'cursor-agent'), + logout: (): Promise<{ opened: boolean; error?: string }> => + ipcRenderer.invoke('settings:cursor-agent:logout'), + }, privacy: { get: (): Promise<{ telemetry: boolean; telemetryUpdatedAt: string | null; version: number }> => ipcRenderer.invoke('consent:get'), @@ -168,7 +180,7 @@ contextBridge.exposeInMainWorld('electronAPI', { }, sessions: { create: ( - promptOrPayload: string | { prompt: string; attachments?: Array<{ name: string; mime: string; bytes: Uint8Array }>; engine?: string }, + promptOrPayload: string | { prompt: string; attachments?: Array<{ name: string; mime: string; bytes: Uint8Array }>; engine?: string; model?: string }, ): Promise => ipcRenderer.invoke('sessions:create', promptOrPayload), start: (id: string): Promise => ipcRenderer.invoke('sessions:start', id), cancel: (id: string): Promise => ipcRenderer.invoke('sessions:cancel', id), @@ -187,6 +199,15 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('sessions:open-in-editor', { editorId, filePath }), listEngines: (): Promise> => ipcRenderer.invoke('sessions:list-engines'), + listEngineModels: (engineId: string, opts?: { forceRefresh?: boolean }): Promise<{ + engineId: string; + models: Array<{ id: string; displayName: string; description?: string; source: string; isDefault?: boolean; isCurrent?: boolean; hidden?: boolean; supportedReasoningEfforts?: string[] }>; + source: string; + error?: string; + cached?: boolean; + cachedAt?: number; + expiresAt?: number; + }> => ipcRenderer.invoke('sessions:list-engine-models', engineId, opts), engineStatus: (engineId: string): Promise<{ id: string; displayName: string; diff --git a/app/src/renderer/globals.d.ts b/app/src/renderer/globals.d.ts index 875762c6..f248db48 100644 --- a/app/src/renderer/globals.d.ts +++ b/app/src/renderer/globals.d.ts @@ -34,7 +34,7 @@ declare module '*.webp' { interface ElectronSessionAPI { create: ( - promptOrPayload: string | { prompt: string; attachments?: Array<{ name: string; mime: string; bytes: Uint8Array }>; engine?: string }, + promptOrPayload: string | { prompt: string; attachments?: Array<{ name: string; mime: string; bytes: Uint8Array }>; engine?: string; model?: string }, ) => Promise; start: (id: string) => Promise; cancel: (id: string) => Promise; @@ -47,6 +47,24 @@ interface ElectronSessionAPI { listEditors: () => Promise>; openInEditor: (editorId: string, filePath: string) => Promise<{ opened: boolean }>; listEngines: () => Promise>; + listEngineModels: (engineId: string, opts?: { forceRefresh?: boolean }) => Promise<{ + engineId: string; + models: Array<{ + id: string; + displayName: string; + description?: string; + source: string; + isDefault?: boolean; + isCurrent?: boolean; + hidden?: boolean; + supportedReasoningEfforts?: string[]; + }>; + source: string; + error?: string; + cached?: boolean; + cachedAt?: number; + expiresAt?: number; + }>; engineStatus: (engineId: string) => Promise<{ id: string; displayName: string; @@ -226,6 +244,17 @@ interface ElectronSettingsCodexAPI { logout: () => Promise<{ opened: boolean; error?: string }>; } +interface ElectronSettingsCursorAPI { + status: () => Promise<{ + id: string; + displayName: string; + installed: { installed: boolean; version?: string; error?: string }; + authed: { authed: boolean; error?: string }; + }>; + login: () => Promise<{ opened: boolean; error?: string }>; + logout: () => Promise<{ opened: boolean; error?: string }>; +} + interface ElectronSettingsAppAPI { getUpdateStatus: () => Promise<{ status: 'idle' | 'checking' | 'downloading' | 'ready' | 'error' | 'unavailable'; @@ -278,6 +307,7 @@ interface ElectronSettingsAPI { claudeCode?: ElectronSettingsClaudeCodeAPI; openaiKey?: ElectronSettingsOpenAiKeyAPI; codex?: ElectronSettingsCodexAPI; + cursor?: ElectronSettingsCursorAPI; app?: ElectronSettingsAppAPI; } diff --git a/app/src/renderer/hub/AgentPane.tsx b/app/src/renderer/hub/AgentPane.tsx index 72ef79e5..9544cd4c 100644 --- a/app/src/renderer/hub/AgentPane.tsx +++ b/app/src/renderer/hub/AgentPane.tsx @@ -1034,6 +1034,11 @@ export function AgentPane({ session, focused, onRerun, onFollowUp, onDismiss, on {session.engine === 'claude-code' && ( Claude Code )} + {session.model && ( + + {session.model} + + )} {session.authMode && ( ({ installed: false, authed: false }); const [codexWaiting, setCodexWaiting] = useState(false); + const [cursorStatus, setCursorStatus] = useState({ installed: false, authed: false }); + const [cursorWaiting, setCursorWaiting] = useState(false); // Surfaced from the codex login PTY when --device-auth is in play. Drives // the small "one-time code" block below the Codex card so users on // restricted networks (no localhost-callback) can still sign in. @@ -106,6 +115,22 @@ export function ConnectionsPane({ embedded }: ConnectionsPaneProps): React.React } }, []); + const refreshCursor = useCallback(async () => { + const api = window.electronAPI; + if (!api?.settings?.cursor) return; + try { + const s = await api.settings.cursor.status(); + setCursorStatus({ + installed: s.installed.installed, + authed: s.authed.authed, + version: s.installed.version, + error: s.installed.error ?? s.authed.error, + }); + } catch (err) { + console.error('[connections] refreshCursor failed', err); + } + }, []); + const handleUseClaudeCode = useCallback(async () => { const api = window.electronAPI; if (!api?.settings?.claudeCode) return; @@ -173,7 +198,8 @@ export function ConnectionsPane({ embedded }: ConnectionsPaneProps): React.React refreshKey(); refreshOpenai(); refreshCodex(); - }, [refreshKey, refreshOpenai, refreshCodex]); + refreshCursor(); + }, [refreshKey, refreshOpenai, refreshCodex, refreshCursor]); // Periodic refresh while the pane is mounted — catches external state // changes (user runs `claude auth logout` in a terminal, codex token @@ -184,9 +210,10 @@ export function ConnectionsPane({ embedded }: ConnectionsPaneProps): React.React refreshKey(); refreshOpenai(); refreshCodex(); + refreshCursor(); }, 5000); return () => clearInterval(id); - }, [refreshKey, refreshOpenai, refreshCodex]); + }, [refreshKey, refreshOpenai, refreshCodex, refreshCursor]); // Poll codex status while user completes the codex OAuth flow. Tighter // interval than the 5s panel refresh so the UI flips to "Signed in" the @@ -213,6 +240,46 @@ export function ConnectionsPane({ embedded }: ConnectionsPaneProps): React.React return () => { cancelled = true; }; }, [codexWaiting, refreshCodex, codexStatus.authed]); + // Poll cursor status while user completes the `agent login` OAuth flow. + useEffect(() => { + if (!cursorWaiting) return; + let cancelled = false; + let attempts = 0; + const MAX = 180; + const tick = async () => { + if (cancelled) return; + attempts++; + await refreshCursor(); + if (cursorStatus.authed) { + setCursorWaiting(false); + return; + } + if (attempts >= MAX) { setCursorWaiting(false); return; } + setTimeout(tick, 1000); + }; + void tick(); + return () => { cancelled = true; }; + }, [cursorWaiting, refreshCursor, cursorStatus.authed]); + + const handleCursorLogin = useCallback(async () => { + const api = window.electronAPI; + if (!api?.settings?.cursor) return; + setCursorWaiting(true); + const res = await api.settings.cursor.login(); + if (!res.opened) { + console.warn('[connections] cursor login failed', res.error); + setCursorWaiting(false); + } + }, []); + + const handleCursorLogout = useCallback(async () => { + const api = window.electronAPI; + if (!api?.settings?.cursor?.logout) return; + const res = await api.settings.cursor.logout(); + if (!res.opened) console.warn('[connections] cursor logout failed', res.error); + await refreshCursor(); + }, [refreshCursor]); + const handleSaveOpenai = useCallback(async () => { const api = window.electronAPI; if (!api?.settings?.openaiKey) return; @@ -596,6 +663,43 @@ export function ConnectionsPane({ embedded }: ConnectionsPaneProps): React.React )} +
+
+ +
+
+ Cursor + +
+ + {cursorStatus.authed + ? `Signed in with Cursor${cursorStatus.version ? ` · v${cursorStatus.version}` : ''}` + : cursorWaiting + ? 'Finish the OAuth flow in your browser…' + : !cursorStatus.installed + ? 'Cursor Agent CLI not installed — run `curl https://cursor.com/install -fsS | bash`' + : 'Not connected'} + +
+
+ {cursorStatus.authed && ( + + )} + {!cursorStatus.authed && cursorStatus.installed && ( + + )} +
+
+
+
; @@ -22,6 +52,9 @@ function EngineLogo({ id }: { id: string }): React.ReactElement { if (id === 'codex') { return ; } + if (id === 'cursor-agent') { + return ; + } return ( @@ -39,23 +72,94 @@ function ChevronIcon(): React.ReactElement { ); } +function RefreshIcon(): React.ReactElement { + return ( + + ); +} + +function BackIcon(): React.ReactElement { + return ( + + ); +} + +function ForwardIcon(): React.ReactElement { + return ( + + ); +} + interface EnginePickerProps { value: string; onChange: (engineId: string) => void; + model?: string; + modelByEngine?: Record; + onModelChange?: (modelId: string | undefined) => void; + labelMode?: 'engine-model' | 'model'; /** Fires when the dropdown opens/closes. Used by hosts (e.g. the pill - * renderer) that need to grow their window so the menu isn't clipped. */ + * renderer) that need to grow their window so the menu isn't clipped. + * The menu's pixel height is the exported MENU_HEIGHT constant — hosts + * that auto-size can rely on it being fixed. */ onOpenChange?: (open: boolean) => void; } -export function EnginePicker({ value, onChange, onOpenChange }: EnginePickerProps): React.ReactElement { +/** Fixed height of the dropdown menu (in CSS px). Exported so hosts that + * auto-size their window (the pill) can compute the height they need to + * reserve for the menu. Keep in sync with `.engine-picker__menu` in hub.css. */ +export const ENGINE_PICKER_MENU_HEIGHT = 200; + +export function EnginePicker({ + value, + onChange, + model, + modelByEngine, + onModelChange, + labelMode = 'engine-model', + onOpenChange, +}: EnginePickerProps): React.ReactElement { const [engines, setEngines] = useState([]); const [statuses, setStatuses] = useState>({}); + const [modelLists, setModelLists] = useState>({}); const [open, setOpen] = useState(false); + const [menuView, setMenuView] = useState('providers'); + const [modelViewEngineId, setModelViewEngineId] = useState(null); + const [selectedProviderId, setSelectedProviderId] = useState(null); + const [modelSearch, setModelSearch] = useState(''); const [loggingIn, setLoggingIn] = useState(null); const menuRef = useRef(null); + const toggleRef = useRef(null); + const [direction, setDirection] = useState<'up' | 'down'>('up'); useEffect(() => { onOpenChange?.(open); }, [open, onOpenChange]); + // Pick open direction based on available space above the toggle. The menu + // has a fixed height (ENGINE_PICKER_MENU_HEIGHT) — if there isn't enough + // upward room, open downward. Hosts that auto-size (the pill) will grow + // their window to make the downward room. + useEffect(() => { + if (!open) return; + const btn = toggleRef.current; + if (!btn) return; + const r = btn.getBoundingClientRect(); + setDirection(r.top >= ENGINE_PICKER_MENU_HEIGHT ? 'up' : 'down'); + }, [open]); + + useEffect(() => { + if (!open) { + setMenuView('providers'); + setModelViewEngineId(null); + setModelSearch(''); + } + }, [open]); + const refreshStatus = useCallback(async (ids: string[]) => { const updates = await Promise.all( ids.map(async (id) => { @@ -70,6 +174,38 @@ export function EnginePicker({ value, onChange, onOpenChange }: EnginePickerProp }); }, []); + const refreshModels = useCallback(async (engineId: string, force = false) => { + if (!onModelChange) return; + const existing = modelLists[engineId]; + const isFresh = Boolean(existing?.response && (!existing.response.expiresAt || Date.now() < existing.response.expiresAt)); + if (!force && (existing?.loading || isFresh)) return; + setModelLists((prev) => ({ + ...prev, + [engineId]: { ...prev[engineId], loading: true, error: undefined }, + })); + try { + const response = await window.electronAPI?.sessions?.listEngineModels?.(engineId, { forceRefresh: force }); + setModelLists((prev) => ({ + ...prev, + [engineId]: { + loading: false, + response: response ?? { engineId, models: [], source: 'static', error: 'Model listing unavailable' }, + error: response?.error, + }, + })); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to list models'; + setModelLists((prev) => ({ + ...prev, + [engineId]: { + loading: false, + response: { engineId, models: [], source: 'static', error: message }, + error: message, + }, + })); + } + }, [modelLists, onModelChange]); + // Mount: fetch engine list + initial statuses. useEffect(() => { let cancelled = false; @@ -92,6 +228,17 @@ export function EnginePicker({ value, onChange, onOpenChange }: EnginePickerProp void refreshStatus(engines.map((e) => e.id)); }, [open, engines, refreshStatus]); + // Background-load model catalogs once connection checks say an engine is + // installed and authenticated. The main process cache keeps this cheap. + useEffect(() => { + if (!onModelChange) return; + for (const engine of engines) { + const st = statuses[engine.id]; + if (!st?.installed?.installed || !st?.authed?.authed) continue; + void refreshModels(engine.id); + } + }, [engines, statuses, onModelChange, refreshModels]); + // Close on outside click. useEffect(() => { if (!open) return; @@ -127,9 +274,40 @@ export function EnginePicker({ value, onChange, onOpenChange }: EnginePickerProp const currentStatus = currentEngine ? statuses[currentEngine.id] : undefined; const currentInstalled = currentStatus?.installed?.installed ?? true; const currentAuthed = currentStatus?.authed?.authed ?? true; + const currentModels = value ? modelLists[value]?.response?.models ?? [] : []; + const currentModel = model ? currentModels.find((m) => m.id === model) : undefined; + const currentModelLabel = model ? (currentModel?.displayName ?? model) : 'Default'; + const modelOnlyLabel = labelMode === 'model'; + + const modelLabelFor = (engineId: string): string | null => { + const m = modelByEngine ? modelByEngine[engineId] : (engineId === value ? model : undefined); + if (!m) return null; + const list = modelLists[engineId]?.response?.models ?? []; + const found = list.find((x) => x.id === m); + return found?.displayName ?? m; + }; + + const openModelsForEngine = (id: string) => { + if (!onModelChange) { + // No model picker — selecting a provider here is the commit point. + onChange(id); + setSelectedProviderId(id); + setOpen(false); + return; + } + // Just navigate; don't commit engine until the user picks a model. + setModelViewEngineId(id); + setMenuView('models'); + setModelSearch(''); + const st = statuses[id]; + if (st?.installed?.installed && st?.authed?.authed) void refreshModels(id); + }; - const selectEngine = (id: string) => { - onChange(id); + const selectModel = (modelId: string | undefined) => { + const engineId = modelViewEngineId ?? value; + if (engineId && engineId !== value) onChange(engineId); + setSelectedProviderId(engineId); + onModelChange?.(modelId); setOpen(false); }; @@ -145,38 +323,75 @@ export function EnginePicker({ value, onChange, onOpenChange }: EnginePickerProp if (engines.length === 0) return ; + const modelEngineId = modelViewEngineId ?? value; + const modelEngine = engines.find((e) => e.id === modelEngineId) ?? currentEngine; + const pageModel = modelEngine + ? (modelByEngine ? modelByEngine[modelEngine.id] : (modelEngine.id === value ? model : undefined)) + : undefined; + const isCommittedEngine = modelEngine?.id === value; + const modelStatus = modelEngine ? statuses[modelEngine.id] : undefined; + const modelInstalled = modelStatus?.installed?.installed ?? true; + const modelAuthed = modelStatus?.authed?.authed ?? true; + const modelConnected = modelInstalled && modelAuthed; + const modelState = modelEngine ? modelLists[modelEngine.id] : undefined; + const modelResponse = modelState?.response; + const models = modelResponse?.models ?? []; + const normalizedSearch = modelSearch.trim().toLowerCase(); + const visibleModels = normalizedSearch + ? models.filter((m) => { + const haystack = `${m.displayName} ${m.id} ${m.description ?? ''}`.toLowerCase(); + return haystack.includes(normalizedSearch); + }) + : models; + const showModelSearch = modelConnected && models.length > 10; + const selectedEngineId = selectedProviderId ?? value ?? null; + return (
{open && ( -
- {engines.map((e) => { +
+ {menuView === 'providers' && engines.map((e) => { const st = statuses[e.id]; const installed = st?.installed?.installed ?? true; const authed = st?.authed?.authed ?? true; const needsSetup = !installed || !authed; + const selected = e.id === selectedEngineId; return ( -
+
{needsSetup && installed && ( + + + {modelEngine.displayName} + + + {!modelInstalled && Not installed} + {modelInstalled && !modelAuthed && Connect first} + {modelConnected && modelState?.loading && Loading} + {modelConnected && ( + + )} + +
+ {showModelSearch && ( +
+ setModelSearch(e.target.value)} + placeholder={`Search models (${models.length} available)`} + aria-label={`Search ${modelEngine.displayName} models (${models.length} available)`} + autoFocus + /> +
+ )} +
+ + {isCommittedEngine && pageModel && !models.some((m) => m.id === pageModel) && ( + + )} + {visibleModels.map((m) => { + const isSelected = isCommittedEngine && pageModel === m.id; + return ( + + ); + })} + {showModelSearch && visibleModels.length === 0 && ( +
No matching models
+ )} +
+ {(modelResponse?.error || modelState?.error) && ( +
+ Using fallback model list +
+ )} +
+ )}
)}
diff --git a/app/src/renderer/hub/HubApp.tsx b/app/src/renderer/hub/HubApp.tsx index e857e3cf..6db2f632 100644 --- a/app/src/renderer/hub/HubApp.tsx +++ b/app/src/renderer/hub/HubApp.tsx @@ -350,10 +350,11 @@ export function HubApp(): React.ReactElement { } }, [focusIndex, sessions, gridColumns, gridPage]); - const handleCreateSession = useCallback(async (input: string | { prompt: string; attachments?: Array<{ name: string; mime: string; bytes: Uint8Array }>; engine?: string }) => { + const handleCreateSession = useCallback(async (input: string | { prompt: string; attachments?: Array<{ name: string; mime: string; bytes: Uint8Array }>; engine?: string; model?: string }) => { const prompt = typeof input === 'string' ? input : input.prompt; const attachments = typeof input === 'string' ? [] : (input.attachments ?? []); const engine = typeof input === 'string' ? undefined : input.engine; + const model = typeof input === 'string' ? undefined : input.model; if (isMock) { const id = `session-${++sessionCounter}`; const now = Date.now(); @@ -389,10 +390,10 @@ export function HubApp(): React.ReactElement { if (!api) { console.error('[HubApp] electronAPI not available'); return; } try { - console.log('[HubApp] createSession (live)', { prompt, attachmentCount: attachments.length }); + console.log('[HubApp] createSession (live)', { prompt, attachmentCount: attachments.length, engine, model }); const id = await api.sessions.create( - attachments.length > 0 || engine - ? { prompt, attachments, engine } + attachments.length > 0 || engine || model + ? { prompt, attachments, engine, model } : prompt, ); console.log('[HubApp] session created', { id }); diff --git a/app/src/renderer/hub/TaskInput.tsx b/app/src/renderer/hub/TaskInput.tsx index 26fc6386..c52ba4da 100644 --- a/app/src/renderer/hub/TaskInput.tsx +++ b/app/src/renderer/hub/TaskInput.tsx @@ -19,6 +19,7 @@ export interface TaskInputSubmission { prompt: string; attachments: TaskInputAttachment[]; engine: string; + model?: string; } interface TaskInputProps { @@ -26,6 +27,7 @@ interface TaskInputProps { } const ENGINE_STORAGE_KEY = 'hub.selectedEngine'; +const MODEL_STORAGE_PREFIX = 'hub.selectedModel.'; const DEFAULT_ENGINE = 'claude-code'; function loadStoredEngine(): string { @@ -37,6 +39,25 @@ function loadStoredEngine(): string { } } +function loadStoredModel(engineId: string): string | undefined { + try { + const v = localStorage.getItem(`${MODEL_STORAGE_PREFIX}${engineId}`); + return v && v.length > 0 ? v : undefined; + } catch { + return undefined; + } +} + +function storeSelectedModel(engineId: string, model: string | undefined): void { + try { + const key = `${MODEL_STORAGE_PREFIX}${engineId}`; + if (model) localStorage.setItem(key, model); + else localStorage.removeItem(key); + } catch { + // ignore + } +} + export interface TaskInputHandle { addFiles: (files: FileList | File[]) => Promise; focus: () => void; @@ -78,6 +99,11 @@ export const TaskInput = forwardRef(function Ta const [errorMsg, setErrorMsg] = useState(null); const [dragActive, setDragActive] = useState(false); const [engine, setEngine] = useState(() => loadStoredEngine()); + const [modelsByEngine, setModelsByEngine] = useState>(() => { + const storedEngine = loadStoredEngine(); + const storedModel = loadStoredModel(storedEngine); + return storedModel ? { [storedEngine]: storedModel } : {}; + }); const textareaRef = useRef(null); const fileInputRef = useRef(null); @@ -121,19 +147,26 @@ export const TaskInput = forwardRef(function Ta const submit = useCallback(() => { const trimmed = value.trim(); if (!trimmed && attachments.length === 0) return; - console.log('[TaskInput] submit', { promptLength: trimmed.length, attachmentCount: attachments.length }); - onSubmit({ prompt: trimmed, attachments, engine }); + const model = modelsByEngine[engine]; + console.log('[TaskInput] submit', { promptLength: trimmed.length, attachmentCount: attachments.length, engine, model }); + onSubmit({ prompt: trimmed, attachments, engine, model }); setValue(''); setAttachments([]); setErrorMsg(null); textareaRef.current?.focus(); - }, [value, attachments, engine, onSubmit]); + }, [value, attachments, engine, modelsByEngine, onSubmit]); const onEngineChange = useCallback((id: string) => { setEngine(id); + setModelsByEngine((prev) => (id in prev ? prev : { ...prev, [id]: loadStoredModel(id) })); try { localStorage.setItem(ENGINE_STORAGE_KEY, id); } catch { /* ignore */ } }, []); + const onModelChange = useCallback((model: string | undefined) => { + setModelsByEngine((prev) => ({ ...prev, [engine]: model })); + storeSelectedModel(engine, model); + }, [engine]); + const onKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -225,7 +258,13 @@ export const TaskInput = forwardRef(function Ta > - + Cursor diff --git a/app/src/renderer/hub/hub.css b/app/src/renderer/hub/hub.css index 1471c558..21fcaf30 100644 --- a/app/src/renderer/hub/hub.css +++ b/app/src/renderer/hub/hub.css @@ -767,6 +767,19 @@ opacity: 0.85; } +.pane__model-badge { + flex-shrink: 1; + min-width: 0; + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 10px; + font-family: var(--font-family-mono); + color: var(--color-text-secondary); + line-height: 1; +} + /* Auth-mode badge — "MAX" / "PRO" / "KEY" / "CHATGPT". Frozen at session spawn so it reflects the mode that actually ran, not current settings. */ .pane__auth-badge { @@ -1642,6 +1655,38 @@ button:focus:not(:focus-visible) { white-space: nowrap; } +.engine-picker__label { + display: inline-flex; + align-items: center; + gap: 4px; + min-width: 0; + max-width: 190px; +} + +.engine-picker__model { + color: var(--color-fg-tertiary); + max-width: 110px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.engine-picker__model::before { + content: '/'; + margin-right: 4px; + color: var(--color-fg-disabled, var(--color-fg-tertiary)); +} + +.engine-picker__toggle--model-only .engine-picker__label { + max-width: 130px; +} + +.engine-picker__toggle--model-only .engine-picker__name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + .engine-picker__dot { width: 6px; height: 6px; @@ -1651,9 +1696,14 @@ button:focus:not(:focus-visible) { .engine-picker__menu { position: absolute; - bottom: calc(100% + 4px); right: 0; - min-width: 220px; + min-width: 240px; + max-width: min(360px, calc(100vw - 24px)); + /* Fixed height so the menu is deterministic across hosts (hub + pill) and + doesn't depend on viewport size or content measurement. The inner list + scrolls when there are more entries than fit. */ + height: 200px; + overflow: hidden; background-color: var(--color-bg-elevated); background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)); border: 1px solid var(--color-border-default); @@ -1669,6 +1719,22 @@ button:focus:not(:focus-visible) { gap: 1px; } +.engine-picker__menu--up { + bottom: calc(100% + 4px); +} + +.engine-picker__menu--down { + top: calc(100% + 4px); +} + +.engine-picker__menu--providers { + overflow-y: auto; +} + +.engine-picker__menu--models { + min-width: 320px; +} + .engine-picker__item { display: flex; align-items: center; @@ -1678,6 +1744,7 @@ button:focus:not(:focus-visible) { .engine-picker__item--active { background-color: var(--color-bg-sunken); + box-shadow: inset 0 0 0 1px var(--color-border-subtle); } .engine-picker__item-select { @@ -1700,10 +1767,72 @@ button:focus:not(:focus-visible) { background-color: var(--color-bg-hover, var(--color-border-subtle)); } +.engine-picker__item-select:focus, +.engine-picker__item-select:focus-visible { + outline: none; + box-shadow: none; +} + +.engine-picker__item .engine-logo, +.engine-picker__item .engine-logo svg { + width: 18px; + height: 18px; +} + +.engine-picker__item-select { + min-height: 44px; +} + +.engine-picker__item-text { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; + gap: 1px; +} + .engine-picker__item-name { flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.engine-picker__item-sub { + color: var(--color-fg-tertiary); + font-size: var(--font-size-xs); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-height: 1em; +} + +.engine-picker__item-check { + display: inline-flex; + align-items: center; + justify-content: center; + width: 12px; + color: var(--color-fg-tertiary); + font-size: var(--font-size-xs); +} + +.engine-picker__item-arrow { + color: var(--color-fg-tertiary); + display: inline-flex; + opacity: 0; + transform: translateX(-2px); + transition: + opacity var(--duration-fast) var(--ease-out), + transform var(--duration-fast) var(--ease-out); +} + +.engine-picker__item:hover .engine-picker__item-arrow { + opacity: 1; + transform: translateX(0); } + .engine-picker__check { color: var(--color-fg-tertiary); font-size: var(--font-size-xs); @@ -1737,6 +1866,208 @@ button:focus:not(:focus-visible) { padding-right: 8px; } +.engine-picker__models { + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; + height: 100%; + min-height: 0; +} + +.engine-picker__models-title { + display: flex; + align-items: center; + gap: 8px; + padding: 2px 4px 6px; + color: var(--color-fg-primary); + font-size: var(--font-size-sm); + text-transform: none; + letter-spacing: 0; +} + +.engine-picker__models-back { + appearance: none; + border: 0; + background: transparent; + color: var(--color-fg-tertiary); + width: 22px; + height: 22px; + border-radius: var(--radius-sm); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.engine-picker__models-back:hover { + color: var(--color-fg-primary); + background-color: var(--color-bg-hover, var(--color-border-subtle)); +} + +.engine-picker__models-provider { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} + +.engine-picker__models-provider > span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.engine-picker__models-status { + color: var(--color-fg-disabled, var(--color-fg-tertiary)); + font-size: var(--font-size-xs); + text-transform: none; +} + +.engine-picker__models-title-actions { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; +} + +.engine-picker__models-refresh { + appearance: none; + border: 0; + background: transparent; + color: var(--color-fg-tertiary); + width: 18px; + height: 18px; + border-radius: var(--radius-sm); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.engine-picker__models-refresh:hover:not(:disabled) { + color: var(--color-fg-primary); + background-color: var(--color-bg-hover, var(--color-border-subtle)); +} + +.engine-picker__models-refresh:disabled { + opacity: 0.5; + cursor: default; +} + +.engine-picker__model-search { + padding: 0 4px 4px; +} + +.engine-picker__model-search-input { + appearance: none; + width: 100%; + min-width: 0; + height: 28px; + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-sm); + background-color: var(--color-bg-sunken); + color: var(--color-fg-primary); + padding: 0 8px; + font-size: var(--font-size-sm); + outline: none; +} + +.engine-picker__model-search-input:focus { + border-color: var(--color-border-default); + background-color: var(--color-bg-elevated); +} + +.engine-picker__model-search-input::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + margin-left: 6px; + background-color: var(--color-fg-tertiary); + cursor: pointer; + -webkit-mask: url("data:image/svg+xml,%3Csvg width='12' height='12' viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M3 3L9 9M9 3L3 9' stroke='black' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E") center / 12px 12px no-repeat; + mask: url("data:image/svg+xml,%3Csvg width='12' height='12' viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M3 3L9 9M9 3L3 9' stroke='black' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E") center / 12px 12px no-repeat; +} + +.engine-picker__model-search-input::-webkit-search-decoration { + -webkit-appearance: none; +} + +.engine-picker__models-list { + display: flex; + flex-direction: column; + flex: 1 1 auto; + gap: 2px; + min-height: 0; + overflow-y: auto; + padding-right: 2px; +} + +.engine-picker__model-option { + appearance: none; + width: 100%; + background: transparent; + border: 0; + color: var(--color-fg-primary); + text-align: left; + padding: 6px 8px; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.engine-picker__model-option:hover { + background-color: var(--color-bg-hover, var(--color-border-subtle)); +} + +.engine-picker__model-option:focus, +.engine-picker__model-option:focus-visible { + outline: none; + box-shadow: none; +} + +.engine-picker__model-option-main { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.engine-picker__model-option-name, +.engine-picker__model-option-id { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.engine-picker__model-option-name { + font-size: var(--font-size-sm); +} + +.engine-picker__model-option-id { + color: var(--color-fg-tertiary); + font-size: var(--font-size-xs); + font-family: var(--font-mono); +} + +.engine-picker__model-error { + padding: 4px 8px; + color: var(--color-status-warning, #e5a23b); + font-size: var(--font-size-xs); +} + +.engine-picker__model-empty { + padding: 8px; + color: var(--color-fg-tertiary); + font-size: var(--font-size-xs); +} + .md-output-path { color: var(--color-accent-default); font-family: var(--font-mono); diff --git a/app/src/renderer/hub/types.ts b/app/src/renderer/hub/types.ts index 2a93fd43..2fcde28d 100644 --- a/app/src/renderer/hub/types.ts +++ b/app/src/renderer/hub/types.ts @@ -26,6 +26,7 @@ export interface AgentSession { primarySite?: string | null; lastActivityAt?: number; engine?: string; + model?: string; authMode?: 'apiKey' | 'subscription'; subscriptionType?: string; costUsd?: number; diff --git a/app/src/renderer/pill/Pill.tsx b/app/src/renderer/pill/Pill.tsx index 065d3c16..cf94ce9d 100644 --- a/app/src/renderer/pill/Pill.tsx +++ b/app/src/renderer/pill/Pill.tsx @@ -6,7 +6,7 @@ import { formatBytes, } from '../../shared/attachments'; import { fallbackShortcutPlatform, formatShortcutForPlatform } from '../../shared/hotkeys'; -import { EnginePicker } from '../hub/EnginePicker'; +import { EnginePicker, ENGINE_PICKER_MENU_HEIGHT } from '../hub/EnginePicker'; import { RESULT_ROW_HEIGHT, MAX_RESULTS, @@ -29,6 +29,7 @@ declare global { prompt: string, attachments?: Array<{ name: string; mime: string; bytes: Uint8Array }>, engine?: string, + model?: string, ) => Promise<{ task_id: string }>; hide: () => void; setExpanded: (expanded: boolean | number) => void; @@ -212,9 +213,11 @@ export function Pill(): React.ReactElement { const [sessions, setSessions] = useState([]); const [selectedIdx, setSelectedIdx] = useState(-1); const [engine, setEngine] = useState('claude-code'); + const [model, setModel] = useState(undefined); const [attachments, setAttachments] = useState>([]); const [attachError, setAttachError] = useState(null); const [validFavicons, setValidFavicons] = useState>(new Set()); + const [pickerOpen, setPickerOpen] = useState(false); const checkedDomainsRef = useRef>(new Set()); const ref = useRef(null); const fileInputRef = useRef(null); @@ -323,10 +326,15 @@ export function Pill(): React.ReactElement { const chipsRows = attachments.length > 0 ? Math.ceil(attachments.length / 3) : 0; const chipsHeight = chipsRows * CHIP_ROW_HEIGHT; const errorHeight = attachError ? ERROR_ROW_HEIGHT : 0; - const total = searchHeight + resultHeight + dashboardHeight + chipsHeight + errorHeight + FOOTER_HEIGHT; - console.log('[Pill.resize]', { taHeight, searchHeight, resultHeight, dashboardHeight, chipsHeight, errorHeight, total }); + const contentTotal = searchHeight + resultHeight + dashboardHeight + chipsHeight + errorHeight + FOOTER_HEIGHT; + // When the picker is open, the dropdown opens immediately below the input + // row (4px gap) and has a fixed height. Ensure the window is tall enough + // to fully contain it, with a small margin for the shadow. + const pickerNeeded = pickerOpen ? searchHeight + 4 + ENGINE_PICKER_MENU_HEIGHT + 8 : 0; + const total = Math.max(contentTotal, pickerNeeded); + console.log('[Pill.resize]', { taHeight, searchHeight, resultHeight, dashboardHeight, chipsHeight, errorHeight, total, pickerOpen }); window.pillAPI.setExpanded(total); - }, [hasResults, results.length, value, attachments.length, attachError, showDashboard, hasRecents, recents.length]); + }, [hasResults, results.length, value, attachments.length, attachError, showDashboard, hasRecents, recents.length, pickerOpen]); const addFiles = useCallback(async (files: FileList | File[]) => { setAttachError(null); @@ -373,11 +381,11 @@ export function Pill(): React.ReactElement { } if (!trimmed) return; const attachArg = attachments.length > 0 ? attachments : undefined; - window.pillAPI.submit(trimmed, attachArg, engine); + window.pillAPI.submit(trimmed, attachArg, engine, model); setValue(''); setAttachments([]); setAttachError(null); - }, [value, selectedIdx, navList, showDashboard, attachments, engine]); + }, [value, selectedIdx, navList, showDashboard, attachments, engine, model]); const onKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -398,7 +406,7 @@ export function Pill(): React.ReactElement { const trimmed = value.trim(); if (trimmed) { const attachArg = attachments.length > 0 ? attachments : undefined; - window.pillAPI.submit(trimmed, attachArg, engine); + window.pillAPI.submit(trimmed, attachArg, engine, model); setValue(''); setAttachments([]); setAttachError(null); @@ -408,7 +416,7 @@ export function Pill(): React.ReactElement { submit(); } }, - [submit, value, navList.length, attachments, engine], + [submit, value, navList.length, attachments, engine, model], ); const highlightVisible = hasResults && selectedIdx >= 0; @@ -482,7 +490,14 @@ export function Pill(): React.ReactElement { }} />
- {}} /> + { setEngine(id); setModel(undefined); }} + onModelChange={setModel} + onOpenChange={setPickerOpen} + />