mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(cursor): support model selection (#684)
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user