feat(cursor): support model selection (#684)

This commit is contained in:
SSU-WEI HUANG
2026-05-26 16:13:38 +08:00
committed by GitHub
parent db934a9d5b
commit 1d03f186d6
26 changed files with 597 additions and 19 deletions
@@ -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 <id> (or /model <id> 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')
})
})
+138
View File
@@ -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<ListCursorModelsResponse> | 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<ListCursorModelsResponse> {
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<ListCursorModelsResponse> {
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;
}
@@ -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<Record<string, never>, 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'));
}
}
);
}
@@ -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)