From 7af36e5248bb17e6bce038e05766151b8173e3f2 Mon Sep 17 00:00:00 2001 From: Hugo Biais Date: Tue, 5 May 2026 09:19:28 +0200 Subject: [PATCH 1/5] Add Cursor Agent engine adapter Wraps `agent -p --output-format stream-json --stream-partial-output` so sessions can be driven by Cursor's CLI alongside Claude Code and Codex. Self-registers via the existing engine registry, so the EnginePicker and engineLogin IPC pick it up with no UI changes. --- .../main/hl/engines/cursor-agent/adapter.ts | 362 ++++++++++++++++++ app/src/main/hl/engines/index.ts | 1 + 2 files changed, 363 insertions(+) create mode 100644 app/src/main/hl/engines/cursor-agent/adapter.ts 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..f224677a --- /dev/null +++ b/app/src/main/hl/engines/cursor-agent/adapter.ts @@ -0,0 +1,362 @@ +/** + * 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, + InstallProbe, + ParseContext, + ParseResult, + SpawnContext, +} from '../types'; +import type { HlEvent } from '../../../../shared/session-schemas'; + +const ID = 'cursor-agent'; +const DISPLAY = 'Cursor Agent'; +const BIN = 'agent'; + +// ── 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}` }); + } + }); + }); + }, + + 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.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.trim()) { + events.push({ type: 'thinking', text: txt }); + 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'; From 09e11a0a0e538767a6c951e235a3d694b12989ce Mon Sep 17 00:00:00 2001 From: Hugo Biais Date: Tue, 5 May 2026 09:41:06 +0200 Subject: [PATCH 2/5] Wire Cursor logo into the engine picker --- app/src/renderer/hub/EnginePicker.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/renderer/hub/EnginePicker.tsx b/app/src/renderer/hub/EnginePicker.tsx index 14367dfa..83c57899 100644 --- a/app/src/renderer/hub/EnginePicker.tsx +++ b/app/src/renderer/hub/EnginePicker.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import claudeLogoSrc from './claude-logo.svg?raw'; import openaiLogoSrc from './openai-logo.svg?raw'; +import cursorLogoSrc from './cursor-logo.svg?raw'; export interface EngineInfo { id: string; @@ -22,6 +23,9 @@ function EngineLogo({ id }: { id: string }): React.ReactElement { if (id === 'codex') { return ; } + if (id === 'cursor-agent') { + return ; + } return ( From fc7f06b89099010e0520e535a7442b08859e831b Mon Sep 17 00:00:00 2001 From: Hugo Biais Date: Tue, 5 May 2026 09:46:47 +0200 Subject: [PATCH 3/5] Add Cursor card to the Connections pane --- app/src/main/settings/apiKeyIpc.ts | 7 ++ app/src/preload/shell.ts | 12 +++ app/src/renderer/globals.d.ts | 12 +++ app/src/renderer/hub/ConnectionsPane.tsx | 108 ++++++++++++++++++++++- 4 files changed, 137 insertions(+), 2 deletions(-) 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/shell.ts b/app/src/preload/shell.ts index 0111d4a5..b502cb98 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'), diff --git a/app/src/renderer/globals.d.ts b/app/src/renderer/globals.d.ts index 875762c6..736b6c13 100644 --- a/app/src/renderer/globals.d.ts +++ b/app/src/renderer/globals.d.ts @@ -226,6 +226,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 +289,7 @@ interface ElectronSettingsAPI { claudeCode?: ElectronSettingsClaudeCodeAPI; openaiKey?: ElectronSettingsOpenAiKeyAPI; codex?: ElectronSettingsCodexAPI; + cursor?: ElectronSettingsCursorAPI; app?: ElectronSettingsAppAPI; } diff --git a/app/src/renderer/hub/ConnectionsPane.tsx b/app/src/renderer/hub/ConnectionsPane.tsx index 72335166..16ec8888 100644 --- a/app/src/renderer/hub/ConnectionsPane.tsx +++ b/app/src/renderer/hub/ConnectionsPane.tsx @@ -3,6 +3,7 @@ import anthropicLogo from './anthropic-logo.svg'; import claudeCodeLogo from './claude-code-logo.svg'; import openaiLogo from './openai-logo.svg'; import codexLogo from './codex-logo.svg'; +import cursorLogo from './cursor-logo.svg'; import { CookieBrowser, type CookieBrowserApi } from '../shared/CookieBrowser'; type WaStatus = 'disconnected' | 'connecting' | 'qr_ready' | 'connected' | 'error'; @@ -23,6 +24,12 @@ interface CodexStatus { version?: string; error?: string; } +interface CursorStatus { + installed: boolean; + authed: boolean; + version?: string; + error?: string; +} interface ConnectionsPaneProps { embedded?: boolean; @@ -64,6 +71,8 @@ export function ConnectionsPane({ embedded }: ConnectionsPaneProps): React.React const [codexStatus, setCodexStatus] = useState({ 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 && ( + + )} +
+
+
+
Date: Tue, 5 May 2026 09:50:35 +0200 Subject: [PATCH 4/5] Use white Cursor logo in the Connections card --- app/src/renderer/hub/ConnectionsPane.tsx | 2 +- app/src/renderer/hub/cursor-logo-white.svg | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 app/src/renderer/hub/cursor-logo-white.svg diff --git a/app/src/renderer/hub/ConnectionsPane.tsx b/app/src/renderer/hub/ConnectionsPane.tsx index 16ec8888..23d3ecda 100644 --- a/app/src/renderer/hub/ConnectionsPane.tsx +++ b/app/src/renderer/hub/ConnectionsPane.tsx @@ -3,7 +3,7 @@ import anthropicLogo from './anthropic-logo.svg'; import claudeCodeLogo from './claude-code-logo.svg'; import openaiLogo from './openai-logo.svg'; import codexLogo from './codex-logo.svg'; -import cursorLogo from './cursor-logo.svg'; +import cursorLogo from './cursor-logo-white.svg'; import { CookieBrowser, type CookieBrowserApi } from '../shared/CookieBrowser'; type WaStatus = 'disconnected' | 'connecting' | 'qr_ready' | 'connected' | 'error'; diff --git a/app/src/renderer/hub/cursor-logo-white.svg b/app/src/renderer/hub/cursor-logo-white.svg new file mode 100644 index 00000000..89416799 --- /dev/null +++ b/app/src/renderer/hub/cursor-logo-white.svg @@ -0,0 +1 @@ +Cursor From 1384ee542d1f12f3c032f3e9223b35d5d290138e Mon Sep 17 00:00:00 2001 From: Hugo Biais Date: Tue, 5 May 2026 17:09:30 +0200 Subject: [PATCH 5/5] Preserve whitespace-only deltas in Cursor stream parser Filtering with txt.trim() drops chunks that are just spaces/newlines, which can run adjacent words together when Cursor splits its partial output across deltas. Only skip truly empty strings now, and keep lastNarrative anchored to non-whitespace text. --- app/src/main/hl/engines/cursor-agent/adapter.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/hl/engines/cursor-agent/adapter.ts b/app/src/main/hl/engines/cursor-agent/adapter.ts index f224677a..6cc3195a 100644 --- a/app/src/main/hl/engines/cursor-agent/adapter.ts +++ b/app/src/main/hl/engines/cursor-agent/adapter.ts @@ -284,9 +284,9 @@ const cursorAgentAdapter: EngineAdapter = { for (const block of content) { if (block?.type !== 'text') continue; const txt = typeof block.text === 'string' ? (block.text as string) : ''; - if (txt.trim()) { + if (txt.length > 0) { events.push({ type: 'thinking', text: txt }); - ctx.lastNarrative = txt; + if (txt.trim()) ctx.lastNarrative = txt; } } return { events };