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
+13
View File
@@ -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<CursorModelsResponse> {
return await this.request<CursorModelsResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}/cursor-models`
)
}
async getMachineCursorModels(machineId: string): Promise<CursorModelsResponse> {
return await this.request<CursorModelsResponse>(
`/api/machines/${encodeURIComponent(machineId)}/cursor-models`
)
}
async getMachineOpencodeModelsForCwd(machineId: string, cwd: string): Promise<OpencodeModelsResponse> {
return await this.request<OpencodeModelsResponse>(
`/api/machines/${encodeURIComponent(machineId)}/opencode-models?cwd=${encodeURIComponent(cwd)}`
@@ -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')
})
})
@@ -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)
}
+40 -3
View File
@@ -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: {
<ModelSelector
agent={agent}
model={model}
options={agent === 'codex' ? codexModelOptions : undefined}
isDisabled={isFormDisabled || (agent === 'codex' && Boolean(codexModelsState.error))}
isLoading={agent === 'codex' && codexModelsState.isLoading}
options={
agent === 'codex'
? codexModelOptions
: agent === 'cursor'
? cursorModelOptions
: undefined
}
isDisabled={
isFormDisabled
|| (agent === 'codex' && Boolean(codexModelsState.error))
|| (agent === 'cursor' && Boolean(cursorModelsState.error))
}
isLoading={
(agent === 'codex' && codexModelsState.isLoading)
|| (agent === 'cursor' && cursorModelsState.isLoading)
}
error={agent === 'codex' && codexModelsState.error
? `${t('newSession.model.loadFailed')}: ${codexModelsState.error}`
: agent === 'cursor' && cursorModelsState.error
? `${t('newSession.model.loadFailed')}: ${cursorModelsState.error}`
: null}
onModelChange={setModel}
/>
+29 -4
View File
@@ -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
+49
View File
@@ -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,
}
}
@@ -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()
}
}
}
+2
View File
@@ -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,
+2
View File
@@ -12,6 +12,8 @@ export type {
CodexModelsResponse,
CodexModelSummary,
CommandResponse,
CursorModelsResponse,
CursorModelSummary,
DeleteUploadResponse,
DirectoryEntry,
FileReadResponse,