diff --git a/cli/src/cursor/cursorRemoteLauncher.ts b/cli/src/cursor/cursorRemoteLauncher.ts index b957edc9..3aaaef5c 100644 --- a/cli/src/cursor/cursorRemoteLauncher.ts +++ b/cli/src/cursor/cursorRemoteLauncher.ts @@ -103,7 +103,7 @@ class CursorRemoteLauncher extends RemoteLauncherBase { cwd: session.path, sessionId: cursorSessionId, mode: agentMode, - model: session.model, + model: mode.model, yolo }); diff --git a/cli/src/cursor/runCursor.ts b/cli/src/cursor/runCursor.ts index 0217a8a6..3f2a1d69 100644 --- a/cli/src/cursor/runCursor.ts +++ b/cli/src/cursor/runCursor.ts @@ -67,7 +67,7 @@ export async function runCursor(opts: { const sessionWrapperRef: { current: CursorSession | null } = { current: null }; let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; - const currentModel = opts.model; + let currentModel = opts.model; const lifecycle = createRunnerLifecycle({ session, @@ -85,7 +85,9 @@ export async function runCursor(opts: { return; } sessionInstance.setPermissionMode(currentPermissionMode); - logger.debug(`[cursor] Synced session permission mode: ${currentPermissionMode}`); + sessionInstance.setModel(currentModel); + sessionInstance.pushKeepAlive(); + logger.debug(`[cursor] Synced session mode: permissionMode=${currentPermissionMode}, model=${currentModel}`); }; session.onUserMessage((message, localId) => { @@ -106,12 +108,15 @@ export async function runCursor(opts: { registerSessionConfigRpc({ rpcHandlerManager: session.rpcHandlerManager, flavor: 'cursor', - modelMode: 'ignore', + modelMode: 'nullable', appliedFallback: () => ({ permissionMode: currentPermissionMode }), onApply: (config) => { if (config.permissionMode !== undefined) { currentPermissionMode = config.permissionMode; } + if (config.model !== undefined) { + currentModel = config.model ?? undefined; + } }, onAfterApply: syncSessionMode }); diff --git a/cli/src/cursor/session.ts b/cli/src/cursor/session.ts index cee37067..427e00e2 100644 --- a/cli/src/cursor/session.ts +++ b/cli/src/cursor/session.ts @@ -11,7 +11,7 @@ type LocalLaunchFailure = { export class CursorSession extends AgentSessionBase { readonly cursorArgs?: string[]; - readonly model?: string; + model?: string; readonly startedBy: 'runner' | 'terminal'; readonly startingMode: 'local' | 'remote'; localLaunchFailure: LocalLaunchFailure | null = null; @@ -60,6 +60,10 @@ export class CursorSession extends AgentSessionBase { this.permissionMode = mode; }; + setModel = (model: string | null | undefined): void => { + this.model = model ?? undefined; + }; + recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { this.localLaunchFailure = { message, exitReason }; }; diff --git a/cli/src/modules/common/cursorModels.test.ts b/cli/src/modules/common/cursorModels.test.ts new file mode 100644 index 00000000..6e472965 --- /dev/null +++ b/cli/src/modules/common/cursorModels.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'vitest' +import { parseCursorModelsOutput } from './cursorModels' + +describe('parseCursorModelsOutput', () => { + test('parses Cursor agent model list output', () => { + const result = parseCursorModelsOutput(` +Available models + +auto - Auto +composer-2.5 - Composer 2.5 (current) +composer-2.5-fast - Composer 2.5 Fast (default) +gpt-5.5-high-fast - GPT-5.5 High Fast + +Tip: use --model (or /model in interactive mode) to switch. +`) + + expect(result).toEqual({ + availableModels: [ + { modelId: 'auto', name: 'Auto' }, + { modelId: 'composer-2.5', name: 'Composer 2.5' }, + { modelId: 'composer-2.5-fast', name: 'Composer 2.5 Fast' }, + { modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' }, + ], + currentModelId: 'composer-2.5' + }) + }) + + test('uses default as current when Cursor output has no current marker', () => { + const result = parseCursorModelsOutput(` +Available models +composer-2.5-fast - Composer 2.5 Fast (default) +composer-2.5 - Composer 2.5 +`) + + expect(result.currentModelId).toBe('composer-2.5-fast') + }) +}) diff --git a/cli/src/modules/common/cursorModels.ts b/cli/src/modules/common/cursorModels.ts new file mode 100644 index 00000000..6a06e2d6 --- /dev/null +++ b/cli/src/modules/common/cursorModels.ts @@ -0,0 +1,138 @@ +import { spawn } from 'node:child_process'; +import type { CursorModelsResponse, CursorModelSummary } from '@hapi/protocol/apiTypes'; +import { getErrorMessage } from './rpcResponses'; + +export type ListCursorModelsResponse = CursorModelsResponse; + +interface CacheEntry { + expiresAt: number; + response: ListCursorModelsResponse; +} + +const CACHE_TTL_MS = 60_000; +const PROBE_TIMEOUT_MS = 30_000; +const cache: CacheEntry = { + expiresAt: 0, + response: { success: true, availableModels: [], currentModelId: null } +}; +let inflight: Promise | null = null; + +export function parseCursorModelsOutput(output: string): { + availableModels: CursorModelSummary[]; + currentModelId: string | null; +} { + const availableModels: CursorModelSummary[] = []; + let currentModelId: string | null = null; + + for (const rawLine of output.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line === 'Available models' || line.startsWith('Tip:')) { + continue; + } + + const separatorIndex = line.indexOf(' - '); + if (separatorIndex <= 0) { + continue; + } + + const modelId = line.slice(0, separatorIndex).trim(); + const rawName = line.slice(separatorIndex + 3).trim(); + if (!modelId || !rawName) { + continue; + } + + const isCurrent = /\s*\(current\)\s*$/.test(rawName); + const isDefault = /\s*\(default\)\s*$/.test(rawName); + const name = rawName.replace(/\s*\((?:current|default)\)\s*$/, '').trim(); + availableModels.push(name && name !== modelId ? { modelId, name } : { modelId }); + + if (isCurrent) { + currentModelId = modelId; + } else if (isDefault && currentModelId === null) { + currentModelId = modelId; + } + } + + return { availableModels, currentModelId }; +} + +async function runCursorModelProbe(): Promise { + return await new Promise((resolve, reject) => { + const child = spawn('agent', ['--list-models'], { + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32' + }); + let stdout = ''; + let stderr = ''; + let settled = false; + + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + child.kill('SIGTERM'); + reject(new Error('Cursor model discovery timed out')); + }, PROBE_TIMEOUT_MS); + + child.stdout?.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.on('error', (error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + reject(error); + }); + child.on('exit', (code) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (code !== 0) { + reject(new Error(stderr.trim() || `agent --list-models exited with code ${code}`)); + return; + } + + resolve({ + success: true, + ...parseCursorModelsOutput(stdout) + }); + }); + }); +} + +export async function listCursorModels(): Promise { + if (cache.expiresAt > Date.now()) { + return cache.response; + } + + if (inflight) { + return inflight; + } + + inflight = (async () => { + try { + const response = await runCursorModelProbe(); + cache.expiresAt = Date.now() + CACHE_TTL_MS; + cache.response = response; + return response; + } catch (error) { + return { + success: false, + error: getErrorMessage(error, 'Failed to discover Cursor models') + }; + } finally { + inflight = null; + } + })(); + + return inflight; +} + +export function _resetCursorModelsCacheForTests(): void { + cache.expiresAt = 0; + cache.response = { success: true, availableModels: [], currentModelId: null }; + inflight = null; +} diff --git a/cli/src/modules/common/handlers/cursorModels.ts b/cli/src/modules/common/handlers/cursorModels.ts new file mode 100644 index 00000000..77deee01 --- /dev/null +++ b/cli/src/modules/common/handlers/cursorModels.ts @@ -0,0 +1,24 @@ +import { logger } from '@/ui/logger'; +import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'; +import { + listCursorModels, + type ListCursorModelsResponse +} from '../cursorModels'; +import { getErrorMessage, rpcError } from '../rpcResponses'; + +export function registerCursorModelHandlers(rpcHandlerManager: RpcHandlerManager): void { + rpcHandlerManager.registerHandler, ListCursorModelsResponse>( + RPC_METHODS.ListCursorModels, + async () => { + logger.debug('List Cursor models request'); + + try { + return await listCursorModels(); + } catch (error) { + logger.debug('Failed to list Cursor models:', error); + return rpcError(getErrorMessage(error, 'Failed to list Cursor models')); + } + } + ); +} diff --git a/cli/src/modules/common/registerCommonHandlers.ts b/cli/src/modules/common/registerCommonHandlers.ts index b72fd1b2..b555593a 100644 --- a/cli/src/modules/common/registerCommonHandlers.ts +++ b/cli/src/modules/common/registerCommonHandlers.ts @@ -1,6 +1,7 @@ import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' import { registerBashHandlers } from './handlers/bash' import { registerCodexModelHandlers } from './handlers/codexModels' +import { registerCursorModelHandlers } from './handlers/cursorModels' import { registerOpencodeModelHandlers } from './handlers/opencodeModels' import { registerDirectoryHandlers } from './handlers/directories' import { registerDifftasticHandlers } from './handlers/difftastic' @@ -14,6 +15,7 @@ import { registerUploadHandlers } from './handlers/uploads' export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { registerBashHandlers(rpcHandlerManager, workingDirectory) registerCodexModelHandlers(rpcHandlerManager) + registerCursorModelHandlers(rpcHandlerManager) registerOpencodeModelHandlers(rpcHandlerManager) registerFileHandlers(rpcHandlerManager, workingDirectory) registerDirectoryHandlers(rpcHandlerManager, workingDirectory) diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 8899ee1a..0f371887 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -4,6 +4,8 @@ import type { CodexModelSummary, CodexModelsResponse, CommandResponse, + CursorModelSummary, + CursorModelsResponse, DeleteUploadResponse, DirectoryEntry, FileReadResponse, @@ -31,6 +33,8 @@ export type RpcListDirectoryResponse = ListDirectoryResponse export type RpcPathExistsResponse = PathExistsResponse export type RpcCodexModel = CodexModelSummary export type RpcListCodexModelsResponse = CodexModelsResponse +export type RpcCursorModel = CursorModelSummary +export type RpcListCursorModelsResponse = CursorModelsResponse export type RpcOpencodeModel = OpencodeModelSummary export type RpcListOpencodeModelsResponse = OpencodeModelsResponse @@ -238,6 +242,14 @@ export class RpcGateway { return await this.machineRpc(machineId, RPC_METHODS.ListCodexModels, {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListCodexModelsResponse } + async listCursorModelsForSession(sessionId: string): Promise { + return await this.sessionRpc(sessionId, RPC_METHODS.ListCursorModels, {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListCursorModelsResponse + } + + async listCursorModelsForMachine(machineId: string): Promise { + return await this.machineRpc(machineId, RPC_METHODS.ListCursorModels, {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListCursorModelsResponse + } + async listOpencodeModelsForSession(sessionId: string): Promise { return await this.sessionRpc(sessionId, RPC_METHODS.ListOpencodeModels, {}) as RpcListOpencodeModelsResponse } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 6ca66737..c0d69be3 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -26,7 +26,9 @@ import { type RpcGeneratedImageResponse, type RpcListDirectoryResponse, type RpcListCodexModelsResponse, + type RpcListCursorModelsResponse, type RpcListOpencodeModelsResponse, + type RpcCursorModel, type RpcOpencodeModel, type RpcPathExistsResponse, type RpcReadFileResponse, @@ -44,7 +46,9 @@ export type { RpcGeneratedImageResponse, RpcListDirectoryResponse, RpcListCodexModelsResponse, + RpcListCursorModelsResponse, RpcListOpencodeModelsResponse, + RpcCursorModel, RpcOpencodeModel, RpcPathExistsResponse, RpcReadFileResponse, @@ -906,6 +910,14 @@ export class SyncEngine { return await this.rpcGateway.listCodexModelsForMachine(machineId) } + async listCursorModelsForSession(sessionId: string): Promise { + return await this.rpcGateway.listCursorModelsForSession(sessionId) + } + + async listCursorModelsForMachine(machineId: string): Promise { + return await this.rpcGateway.listCursorModelsForMachine(machineId) + } + async listOpencodeModelsForSession(sessionId: string): Promise { return await this.rpcGateway.listOpencodeModelsForSession(sessionId) } diff --git a/hub/src/web/routes/machines.test.ts b/hub/src/web/routes/machines.test.ts index 90348d5b..21302013 100644 --- a/hub/src/web/routes/machines.test.ts +++ b/hub/src/web/routes/machines.test.ts @@ -120,4 +120,39 @@ describe('machines routes', () => { currentModelId: 'ollama/exaone:4.5-33b-q8' }) }) + + it('returns Cursor models for an online machine', async () => { + const machine = createMachine() + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + listCursorModelsForMachine: async () => ({ + success: true, + availableModels: [ + { modelId: 'composer-2.5', name: 'Composer 2.5' }, + { modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' } + ], + currentModelId: 'composer-2.5' + }) + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine)) + + const response = await app.request('/api/machines/machine-1/cursor-models') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + availableModels: [ + { modelId: 'composer-2.5', name: 'Composer 2.5' }, + { modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' } + ], + currentModelId: 'composer-2.5' + }) + }) }) diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index 7feb35da..7288a42d 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -163,5 +163,28 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.get('/machines/:id/cursor-models', async (c) => { + const engine = getSyncEngine() + if (!engine) { + return c.json({ success: false, error: 'Not connected' }, 503) + } + + const machineId = c.req.param('id') + const machine = requireMachine(c, engine, machineId) + if (machine instanceof Response) { + return machine + } + + try { + const result = await engine.listCursorModelsForMachine(machineId) + return c.json(result) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list Cursor models' + }, 500) + } + }) + return app } diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index 84c670ae..6f944320 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -72,11 +72,20 @@ function createApp(session: Session, opts?: { ], currentModelId: 'ollama/exaone:4.5-33b-q8' }) + const listCursorModelsForSession = async () => ({ + success: true, + availableModels: [ + { modelId: 'composer-2.5', name: 'Composer 2.5' }, + { modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' } + ], + currentModelId: 'composer-2.5' + }) const resumeSession = opts?.resumeSession ?? (async (sessionId: string) => ({ type: 'success', sessionId })) const engine = { resolveSessionAccess: () => ({ ok: true, sessionId: session.id, session }), applySessionConfig, listCodexModelsForSession, + listCursorModelsForSession, listOpencodeModelsForSession, resumeSession, listSlashCommands: opts?.listSlashCommands ?? (async () => ({ @@ -304,7 +313,7 @@ describe('sessions routes', () => { ]) }) - it('rejects model changes for Cursor sessions', async () => { + it('applies model changes for Cursor sessions', async () => { const session = createSession({ metadata: { path: '/tmp/project', @@ -320,8 +329,10 @@ describe('sessions routes', () => { body: JSON.stringify({ model: 'sonnet' }) }) - expect(response.status).toBe(400) - expect(applySessionConfigCalls).toEqual([]) + expect(response.status).toBe(200) + expect(applySessionConfigCalls).toEqual([ + ['session-1', { model: 'sonnet' }] + ]) }) it('rejects effort changes for non-Claude sessions', async () => { @@ -396,6 +407,33 @@ describe('sessions routes', () => { }) }) + it('returns Cursor models for active Cursor sessions', async () => { + const session = createSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'cursor' } + }) + const { app } = createApp(session) + + const response = await app.request('/api/sessions/session-1/cursor-models') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + availableModels: [ + { modelId: 'composer-2.5', name: 'Composer 2.5' }, + { modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' } + ], + currentModelId: 'composer-2.5' + }) + }) + + it('rejects cursor-models for non-Cursor sessions', async () => { + const { app } = createApp(createSession()) + + const response = await app.request('/api/sessions/session-1/cursor-models') + + expect(response.status).toBe(400) + }) + it('rejects opencode-models for non-OpenCode sessions', async () => { const { app } = createApp(createSession()) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index ef672ec4..b081a6e1 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -613,5 +613,35 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.get('/sessions/:id/cursor-models', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) { + return sessionResult + } + + const flavor = sessionResult.session.metadata?.flavor ?? 'claude' + if (flavor !== 'cursor') { + return c.json({ + success: false, + error: 'Cursor models are only available for Cursor sessions' + }, 400) + } + + try { + const result = await engine.listCursorModelsForSession(sessionResult.sessionId) + return c.json(result) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list Cursor models' + }, 500) + } + }) + return app } diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 150bbdbc..5c71d53f 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -302,6 +302,12 @@ export type OpencodeModelsResponse = { export type ListOpencodeModelsResponse = OpencodeModelsResponse +export type CursorModelSummary = OpencodeModelSummary + +export type CursorModelsResponse = OpencodeModelsResponse + +export type ListCursorModelsResponse = CursorModelsResponse + export type SlashCommand = { name: string description?: string diff --git a/shared/src/flavors.test.ts b/shared/src/flavors.test.ts index 9ab14daf..a92595f1 100644 --- a/shared/src/flavors.test.ts +++ b/shared/src/flavors.test.ts @@ -27,8 +27,8 @@ describe('hasCapability', () => { expect(hasCapability('codex', Capabilities.Effort)).toBe(false) }) - test('cursor has no capabilities', () => { - expect(hasCapability('cursor', Capabilities.ModelChange)).toBe(false) + test('cursor supports model-change but not effort', () => { + expect(hasCapability('cursor', Capabilities.ModelChange)).toBe(true) expect(hasCapability('cursor', Capabilities.Effort)).toBe(false) }) @@ -88,7 +88,7 @@ describe('convenience functions', () => { expect(supportsModelChange('gemini')).toBe(true) expect(supportsModelChange('codex')).toBe(true) expect(supportsModelChange('opencode')).toBe(true) - expect(supportsModelChange('cursor')).toBe(false) + expect(supportsModelChange('cursor')).toBe(true) expect(supportsModelChange(null)).toBe(false) }) diff --git a/shared/src/flavors.ts b/shared/src/flavors.ts index d1a99017..a4832e93 100644 --- a/shared/src/flavors.ts +++ b/shared/src/flavors.ts @@ -14,7 +14,7 @@ const FLAVOR_CAPS: Record> = { gemini: new Set([Capabilities.ModelChange]), kimi: new Set([Capabilities.ModelChange]), codex: new Set([Capabilities.ModelChange]), - cursor: new Set([]), + cursor: new Set([Capabilities.ModelChange]), opencode: new Set([Capabilities.ModelChange]), } diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index dd41a4e5..2d77c1a1 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -26,6 +26,7 @@ export const RPC_METHODS = { ListSlashCommands: 'listSlashCommands', ListSkills: 'listSkills', ListCodexModels: 'listCodexModels', + ListCursorModels: 'listCursorModels', ListOpencodeModels: 'listOpencodeModels', ListOpencodeModelsForCwd: 'listOpencodeModelsForCwd' } as const diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 52c25619..860202b7 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -18,6 +18,7 @@ import type { } from '@/types/api' import type { CodexModelsResponse, + CursorModelsResponse, DeleteUploadResponse, FileReadResponse, GitCommandResponse, @@ -494,6 +495,18 @@ export class ApiClient { ) } + async getSessionCursorModels(sessionId: string): Promise { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/cursor-models` + ) + } + + async getMachineCursorModels(machineId: string): Promise { + return await this.request( + `/api/machines/${encodeURIComponent(machineId)}/cursor-models` + ) + } + async getMachineOpencodeModelsForCwd(machineId: string, cwd: string): Promise { return await this.request( `/api/machines/${encodeURIComponent(machineId)}/opencode-models?cwd=${encodeURIComponent(cwd)}` diff --git a/web/src/components/AssistantChat/modelOptions.test.ts b/web/src/components/AssistantChat/modelOptions.test.ts index 7aa4b6f8..512d8472 100644 --- a/web/src/components/AssistantChat/modelOptions.test.ts +++ b/web/src/components/AssistantChat/modelOptions.test.ts @@ -53,6 +53,25 @@ describe('getModelOptionsForFlavor', () => { expect(options).toEqual([]) }) + it('returns only default/current for cursor before models are discovered (no claude fallback)', () => { + const options = getModelOptionsForFlavor('cursor', 'composer-2.5') + expect(options).toEqual([ + { value: null, label: 'Default' }, + { value: 'composer-2.5', label: 'composer-2.5' } + ]) + }) + + it('returns dynamic cursor options when supplied', () => { + const options = getModelOptionsForFlavor('cursor', null, [ + { value: 'composer-2.5', label: 'Composer 2.5' }, + { value: 'gpt-5.5-high-fast', label: 'GPT-5.5 High Fast' } + ]) + expect(options).toEqual([ + { value: 'composer-2.5', label: 'Composer 2.5' }, + { value: 'gpt-5.5-high-fast', label: 'GPT-5.5 High Fast' } + ]) + }) + it('includes the current opencode model when it is missing from explicit options', () => { const options = getModelOptionsForFlavor('opencode', 'ollama/legacy', [ { value: 'ollama/exaone:4.5-33b-q8', label: 'Ollama EXAONE' } @@ -105,4 +124,9 @@ describe('getNextModelForFlavor', () => { const next = getNextModelForFlavor('opencode', null, []) expect(next).toBeNull() }) + + it('keeps the current cursor model when the dynamic list has not loaded', () => { + const next = getNextModelForFlavor('cursor', 'composer-2.5') + expect(next).toBe('composer-2.5') + }) }) diff --git a/web/src/components/AssistantChat/modelOptions.ts b/web/src/components/AssistantChat/modelOptions.ts index c4416cca..30ee7690 100644 --- a/web/src/components/AssistantChat/modelOptions.ts +++ b/web/src/components/AssistantChat/modelOptions.ts @@ -62,6 +62,9 @@ export function getModelOptionsForFlavor( if (flavor === 'opencode') { return [] } + if (flavor === 'cursor') { + return withCurrentModelOption([{ value: null, label: 'Default' }], currentModel) + } // Kimi has no predefined model list — show just the auto/default option. if (flavor === 'kimi') { return withCurrentModelOption([{ value: null, label: 'Default' }], currentModel) @@ -93,6 +96,9 @@ export function getNextModelForFlavor( if (flavor === 'opencode') { return normalizeCurrentModel(currentModel) } + if (flavor === 'cursor') { + return normalizeCurrentModel(currentModel) + } if (flavor === 'kimi') { return normalizeCurrentModel(currentModel) } diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index bebc190a..ebddd1d5 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -5,6 +5,7 @@ import { usePlatform } from '@/hooks/usePlatform' import { useMachinePathsExists } from '@/hooks/useMachinePathsExists' import { useSpawnSession } from '@/hooks/mutations/useSpawnSession' import { useCodexModels } from '@/hooks/queries/useCodexModels' +import { useCursorModelsForMachine } from '@/hooks/queries/useCursorModelsForMachine' import { useOpencodeModelsForCwd } from '@/hooks/queries/useOpencodeModelsForCwd' import { useSessions } from '@/hooks/queries/useSessions' import { useActiveSuggestions, type Suggestion } from '@/hooks/useActiveSuggestions' @@ -127,6 +128,27 @@ export function NewSession(props: { } return options }, [codexModelsState.models, model]) + const cursorModelsState = useCursorModelsForMachine({ + api: props.api, + machineId, + enabled: agent === 'cursor' && Boolean(machineId) + }) + const cursorModelOptions = useMemo(() => { + const options = [{ value: 'auto', label: 'Default' }] + for (const cursorModel of cursorModelsState.availableModels) { + if (cursorModel.modelId === 'auto') { + continue + } + options.push({ + value: cursorModel.modelId, + label: cursorModel.name ?? cursorModel.modelId + }) + } + if (model !== 'auto' && !options.some((option) => option.value === model)) { + options.splice(1, 0, { value: model, label: model }) + } + return options + }, [cursorModelsState.availableModels, model]) const recentPaths = useMemo( () => getRecentPaths(machineId), @@ -411,11 +433,26 @@ export function NewSession(props: { diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 8cd3a4e6..2eb7498d 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -31,6 +31,7 @@ import { TeamPanel } from '@/components/TeamPanel' import { usePlatform } from '@/hooks/usePlatform' import { useSessionActions } from '@/hooks/mutations/useSessionActions' import { useCodexModels } from '@/hooks/queries/useCodexModels' +import { useCursorModels } from '@/hooks/queries/useCursorModels' import { useOpencodeModels } from '@/hooks/queries/useOpencodeModels' import { useVoiceOptional } from '@/lib/voice-context' import { RealtimeVoiceSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime' @@ -167,6 +168,26 @@ export function SessionChat(props: { label: opencodeModel.name ?? opencodeModel.modelId })) }, [agentFlavor, opencodeModelsState.availableModels]) + const cursorModelsState = useCursorModels({ + api: props.api, + sessionId: props.session.id, + enabled: agentFlavor === 'cursor' && props.session.active + }) + const cursorModelOptions = useMemo(() => { + if (agentFlavor !== 'cursor') { + return undefined + } + + return [ + { value: null, label: 'Default' }, + ...cursorModelsState.availableModels + .filter((cursorModel) => cursorModel.modelId !== 'auto') + .map((cursorModel) => ({ + value: cursorModel.modelId, + label: cursorModel.name ?? cursorModel.modelId + })) + ] + }, [agentFlavor, cursorModelsState.availableModels]) const { abortSession, switchSession, @@ -605,9 +626,11 @@ export function SessionChat(props: { availableModelOptions={ agentFlavor === 'codex' ? codexModelOptions - : agentFlavor === 'opencode' - ? opencodeModelOptions - : undefined + : agentFlavor === 'cursor' + ? cursorModelOptions + : agentFlavor === 'opencode' + ? opencodeModelOptions + : undefined } active={props.session.active} allowSendWhenInactive @@ -627,7 +650,9 @@ export function SessionChat(props: { onModelChange={ agentFlavor === 'codex' ? (props.session.active && !controlledByUser && !codexModelsState.error ? handleModelChange : undefined) - : handleModelChange + : agentFlavor === 'cursor' + ? (props.session.active && !cursorModelsState.error ? handleModelChange : undefined) + : handleModelChange } onModelReasoningEffortChange={ agentFlavor === 'codex' && props.session.active && !controlledByUser diff --git a/web/src/hooks/queries/useCursorModels.ts b/web/src/hooks/queries/useCursorModels.ts new file mode 100644 index 00000000..c8228d9b --- /dev/null +++ b/web/src/hooks/queries/useCursorModels.ts @@ -0,0 +1,49 @@ +import { useQuery } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { CursorModelSummary } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +export function useCursorModels(args: { + api: ApiClient | null + sessionId?: string | null + enabled?: boolean +}): { + availableModels: CursorModelSummary[] + currentModelId: string | null + isLoading: boolean + error: string | null +} { + const { api, sessionId } = args + const enabled = Boolean(args.enabled && api && sessionId) + + const query = useQuery({ + queryKey: sessionId + ? queryKeys.sessionCursorModels(sessionId) + : ['session-cursor-models', 'unknown'] as const, + queryFn: async () => { + if (!api) { + throw new Error('API unavailable') + } + if (!sessionId) { + throw new Error('Cursor models target unavailable') + } + return await api.getSessionCursorModels(sessionId) + }, + enabled, + staleTime: 60_000, + retry: false, + }) + + return { + availableModels: query.data?.availableModels ?? [], + currentModelId: query.data?.currentModelId ?? null, + isLoading: query.isLoading, + error: query.data?.success === false + ? (query.data.error ?? 'Failed to load Cursor models') + : query.error instanceof Error + ? query.error.message + : query.error + ? 'Failed to load Cursor models' + : null, + } +} diff --git a/web/src/hooks/queries/useCursorModelsForMachine.ts b/web/src/hooks/queries/useCursorModelsForMachine.ts new file mode 100644 index 00000000..50250803 --- /dev/null +++ b/web/src/hooks/queries/useCursorModelsForMachine.ts @@ -0,0 +1,53 @@ +import { useQuery } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { CursorModelSummary } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +export function useCursorModelsForMachine(args: { + api: ApiClient | null + machineId?: string | null + enabled?: boolean +}): { + availableModels: CursorModelSummary[] + currentModelId: string | null + isLoading: boolean + error: string | null + refetch: () => void +} { + const { api, machineId } = args + const enabled = Boolean(args.enabled && api && machineId) + + const query = useQuery({ + queryKey: machineId + ? queryKeys.machineCursorModels(machineId) + : ['machine-cursor-models', 'unknown'] as const, + queryFn: async () => { + if (!api) { + throw new Error('API unavailable') + } + if (!machineId) { + throw new Error('Cursor models target unavailable') + } + return await api.getMachineCursorModels(machineId) + }, + enabled, + staleTime: 60_000, + retry: false, + }) + + return { + availableModels: query.data?.availableModels ?? [], + currentModelId: query.data?.currentModelId ?? null, + isLoading: query.isLoading, + error: query.data?.success === false + ? (query.data.error ?? 'Failed to load Cursor models') + : query.error instanceof Error + ? query.error.message + : query.error + ? 'Failed to load Cursor models' + : null, + refetch: () => { + void query.refetch() + } + } +} diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index 3222a820..89d0cc0b 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -16,6 +16,8 @@ export const queryKeys = { ] as const, slashCommands: (sessionId: string) => ['slash-commands', sessionId] as const, sessionCodexModels: (sessionId: string) => ['session-codex-models', sessionId] as const, + sessionCursorModels: (sessionId: string) => ['session-cursor-models', sessionId] as const, + machineCursorModels: (machineId: string) => ['machine-cursor-models', machineId] as const, sessionOpencodeModels: (sessionId: string) => ['session-opencode-models', sessionId] as const, machineOpencodeModelsForCwd: (machineId: string, cwd: string) => ['machine-opencode-models', machineId, cwd] as const, skills: (sessionId: string) => ['skills', sessionId] as const, diff --git a/web/src/types/api.ts b/web/src/types/api.ts index ac52a29f..5552b521 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -12,6 +12,8 @@ export type { CodexModelsResponse, CodexModelSummary, CommandResponse, + CursorModelsResponse, + CursorModelSummary, DeleteUploadResponse, DirectoryEntry, FileReadResponse,