Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
362 changes: 362 additions & 0 deletions app/src/main/hl/engines/cursor-agent/adapter.ts

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions app/src/main/hl/engines/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/settings/apiKeyIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand All @@ -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');
Expand Down
12 changes: 12 additions & 0 deletions app/src/preload/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
12 changes: 12 additions & 0 deletions app/src/renderer/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -278,6 +289,7 @@ interface ElectronSettingsAPI {
claudeCode?: ElectronSettingsClaudeCodeAPI;
openaiKey?: ElectronSettingsOpenAiKeyAPI;
codex?: ElectronSettingsCodexAPI;
cursor?: ElectronSettingsCursorAPI;
app?: ElectronSettingsAppAPI;
}

Expand Down
108 changes: 106 additions & 2 deletions app/src/renderer/hub/ConnectionsPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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-white.svg';
import { CookieBrowser, type CookieBrowserApi } from '../shared/CookieBrowser';

type WaStatus = 'disconnected' | 'connecting' | 'qr_ready' | 'connected' | 'error';
Expand All @@ -23,6 +24,12 @@ interface CodexStatus {
version?: string;
error?: string;
}
interface CursorStatus {
installed: boolean;
authed: boolean;
version?: string;
error?: string;
}

interface ConnectionsPaneProps {
embedded?: boolean;
Expand Down Expand Up @@ -64,6 +71,8 @@ export function ConnectionsPane({ embedded }: ConnectionsPaneProps): React.React

const [codexStatus, setCodexStatus] = useState<CodexStatus>({ installed: false, authed: false });
const [codexWaiting, setCodexWaiting] = useState(false);
const [cursorStatus, setCursorStatus] = useState<CursorStatus>({ 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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -596,6 +663,43 @@ export function ConnectionsPane({ embedded }: ConnectionsPaneProps): React.React
)}
</div>

<div className="conn-card">
<div className="conn-card__header">
<img className="conn-card__icon" src={cursorLogo} alt="" />
<div className="conn-card__info">
<div className="conn-card__title-row">
<span className="conn-card__name">Cursor</span>
<span className={`conn-card__dot ${cursorStatus.authed ? 'conn-card__dot--connected' : cursorWaiting ? 'conn-card__dot--connecting' : 'conn-card__dot--disconnected'}`} />
</div>
<span className="conn-card__subtitle">
{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'}
</span>
</div>
<div className="conn-card__actions">
{cursorStatus.authed && (
<button className="conn-card__btn conn-card__btn--secondary" onClick={handleCursorLogout}>
Sign out
</button>
)}
{!cursorStatus.authed && cursorStatus.installed && (
<button
className="conn-card__btn conn-card__btn--primary"
onClick={handleCursorLogin}
disabled={cursorWaiting}
>
{cursorWaiting ? 'Waiting…' : 'Sign in with Cursor'}
</button>
)}
</div>
</div>
</div>

<div className="conn-card">
<div className="conn-card__header">
<img
Expand Down
4 changes: 4 additions & 0 deletions app/src/renderer/hub/EnginePicker.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -22,6 +23,9 @@ function EngineLogo({ id }: { id: string }): React.ReactElement {
if (id === 'codex') {
return <span className="engine-logo" dangerouslySetInnerHTML={{ __html: openaiLogoSrc as string }} />;
}
if (id === 'cursor-agent') {
return <span className="engine-logo" dangerouslySetInnerHTML={{ __html: cursorLogoSrc as string }} />;
}
return (
<span className="engine-logo">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
Expand Down
1 change: 1 addition & 0 deletions app/src/renderer/hub/cursor-logo-white.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.