| author | |
| committer | |
| log | 86e204439e5d065fe1063a2a28df80863528867a |
| tree | 8854254483ca8315577e2852673838e1916024a3 |
| parent | 04a931fc7992152b388faef5cda28e64a0887303 |
- Accept `cursor` as a first-class provider in orchestration, persistence, and session directory flows
- Update model/provider inference and normalization to handle Cursor model aliases
- Revamp chat provider/model picker with Cursor-specific trait controls and add coverage in tests16 files changed, 1145 insertions(+), 85 deletions(-)
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+41-1| ... | ... | @@ -91,7 +91,9 @@ describe("ProviderCommandReactor", () => { |
| 91 | 91 | typeof input === "object" && |
| 92 | 92 | input !== null && |
| 93 | 93 | "provider" in input && |
| 94 | (input.provider === "codex" || input.provider === "claudeCode") | |
| 94 | (input.provider === "codex" || | |
| 95 | input.provider === "claudeCode" || | |
| 96 | input.provider === "cursor") | |
| 95 | 97 | ? input.provider |
| 96 | 98 | : "codex"; |
| 97 | 99 | const resumeCursor = |
| ... | ... | @@ -285,6 +287,44 @@ describe("ProviderCommandReactor", () => { |
| 285 | 287 | expect(thread?.session?.providerSessionId).toBe("sess-1"); |
| 286 | 288 | }); |
| 287 | 289 | |
| 290 | it("starts first turn with cursor provider when provider is specified", async () => { | |
| 291 | const harness = await createHarness(); | |
| 292 | const now = new Date().toISOString(); | |
| 293 | ||
| 294 | await Effect.runPromise( | |
| 295 | harness.engine.dispatch({ | |
| 296 | type: "thread.turn.start", | |
| 297 | commandId: CommandId.makeUnsafe("cmd-turn-start-provider-cursor"), | |
| 298 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 299 | message: { | |
| 300 | messageId: asMessageId("user-message-provider-cursor"), | |
| 301 | role: "user", | |
| 302 | text: "hello cursor", | |
| 303 | attachments: [], | |
| 304 | }, | |
| 305 | provider: "cursor", | |
| 306 | approvalPolicy: "on-request", | |
| 307 | sandboxMode: "workspace-write", | |
| 308 | createdAt: now, | |
| 309 | }), | |
| 310 | ); | |
| 311 | ||
| 312 | await waitFor(() => harness.startSession.mock.calls.length === 1); | |
| 313 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); | |
| 314 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ | |
| 315 | provider: "cursor", | |
| 316 | cwd: "/tmp/provider-project", | |
| 317 | model: "gpt-5-codex", | |
| 318 | approvalPolicy: "on-request", | |
| 319 | sandboxMode: "workspace-write", | |
| 320 | }); | |
| 321 | ||
| 322 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); | |
| 323 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 324 | expect(thread?.session?.providerName).toBe("cursor"); | |
| 325 | expect(thread?.session?.providerSessionId).toBe("sess-1"); | |
| 326 | }); | |
| 327 | ||
| 288 | 328 | it("reuses the same provider session when runtime mode is unchanged", async () => { |
| 289 | 329 | const harness = await createHarness(); |
| 290 | 330 | const now = new Date().toISOString(); |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+3-1| ... | ... | @@ -184,7 +184,9 @@ const make = Effect.gen(function* () { |
| 184 | 184 | const desiredSandboxMode = |
| 185 | 185 | options?.sandboxMode ?? thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE; |
| 186 | 186 | const currentProvider: ProviderKind | undefined = |
| 187 | thread.session?.providerName === "codex" || thread.session?.providerName === "claudeCode" | |
| 187 | thread.session?.providerName === "codex" || | |
| 188 | thread.session?.providerName === "claudeCode" || | |
| 189 | thread.session?.providerName === "cursor" | |
| 188 | 190 | ? thread.session.providerName |
| 189 | 191 | : undefined; |
| 190 | 192 | const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider; |
apps/server/src/persistence/Layers/ProviderSessions.ts+1-1| ... | ... | @@ -13,7 +13,7 @@ import { |
| 13 | 13 | type ProviderSessionRepositoryShape, |
| 14 | 14 | } from "../Services/ProviderSessions.ts"; |
| 15 | 15 | |
| 16 | const ProviderKind = Schema.Literals(["codex", "claudeCode"]); | |
| 16 | const ProviderKind = Schema.Literals(["codex", "claudeCode", "cursor"]); | |
| 17 | 17 | |
| 18 | 18 | const ProviderSessionRowSchema = Schema.Struct({ |
| 19 | 19 | sessionId: ProviderSessionId, |
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+18| ... | ... | @@ -225,4 +225,22 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 225 | 225 | |
| 226 | 226 | fs.rmSync(tempDir, { recursive: true, force: true }); |
| 227 | 227 | })); |
| 228 | ||
| 229 | it("accepts cursor provider bindings", () => | |
| 230 | Effect.gen(function* () { | |
| 231 | const directory = yield* ProviderSessionDirectory; | |
| 232 | const sessionId = ProviderSessionId.makeUnsafe("sess-cursor"); | |
| 233 | const threadId = ThreadId.makeUnsafe("thread-cursor"); | |
| 234 | ||
| 235 | yield* directory.upsert({ | |
| 236 | sessionId, | |
| 237 | provider: "cursor", | |
| 238 | threadId, | |
| 239 | }); | |
| 240 | ||
| 241 | const provider = yield* directory.getProvider(sessionId); | |
| 242 | assert.equal(provider, "cursor"); | |
| 243 | const resolvedThreadId = yield* directory.getThreadId(sessionId); | |
| 244 | assertSome(resolvedThreadId, threadId); | |
| 245 | })); | |
| 228 | 246 | }); |
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+1-1| ... | ... | @@ -26,7 +26,7 @@ function decodeProviderKind( |
| 26 | 26 | providerName: string, |
| 27 | 27 | operation: string, |
| 28 | 28 | ): Effect.Effect<ProviderKind, ProviderSessionDirectoryPersistenceError> { |
| 29 | if (providerName === "codex" || providerName === "claudeCode") { | |
| 29 | if (providerName === "codex" || providerName === "claudeCode" || providerName === "cursor") { | |
| 30 | 30 | return Effect.succeed(providerName); |
| 31 | 31 | } |
| 32 | 32 | return Effect.fail( |
apps/web/src/components/ChatView.tsx+334-71| ... | ... | @@ -1,17 +1,23 @@ |
| 1 | 1 | import { |
| 2 | 2 | type ApprovalRequestId, |
| 3 | CURSOR_REASONING_OPTIONS, | |
| 3 | 4 | DEFAULT_MODEL, |
| 4 | 5 | DEFAULT_REASONING, |
| 5 | 6 | EDITORS, |
| 6 | 7 | type EditorId, |
| 7 | 8 | type KeybindingCommand, |
| 9 | type CursorReasoningOption, | |
| 8 | 10 | type MessageId, |
| 9 | 11 | getDefaultModel, |
| 12 | getCursorModelCapabilities, | |
| 13 | getCursorModelFamilyOptions, | |
| 10 | 14 | getModelOptions, |
| 11 | 15 | getReasoningOptions, |
| 12 | 16 | type ProjectId, |
| 13 | 17 | type ProjectEntry, |
| 14 | 18 | type ProjectScript, |
| 19 | type ModelSlug, | |
| 20 | parseCursorModelSelection, | |
| 15 | 21 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 16 | 22 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 17 | 23 | type ReasoningEffort, |
| ... | ... | @@ -22,6 +28,7 @@ import { |
| 22 | 28 | type ThreadId, |
| 23 | 29 | type TurnId, |
| 24 | 30 | normalizeModelSlug, |
| 31 | resolveCursorModelFromSelection, | |
| 25 | 32 | resolveModelSlugForProvider, |
| 26 | 33 | OrchestrationThreadActivity, |
| 27 | 34 | } from "@t3tools/contracts"; |
| ... | ... | @@ -109,8 +116,6 @@ import { |
| 109 | 116 | import { Button } from "./ui/button"; |
| 110 | 117 | import { |
| 111 | 118 | Select, |
| 112 | SelectGroup, | |
| 113 | SelectGroupLabel, | |
| 114 | 119 | SelectItem, |
| 115 | 120 | SelectPopup, |
| 116 | 121 | SelectTrigger, |
| ... | ... | @@ -118,8 +123,21 @@ import { |
| 118 | 123 | } from "./ui/select"; |
| 119 | 124 | import { Separator } from "./ui/separator"; |
| 120 | 125 | import { Group, GroupSeparator } from "./ui/group"; |
| 121 | import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"; | |
| 122 | import { ClaudeAI, CursorIcon, Icon, OpenAI } from "./Icons"; | |
| 126 | import { | |
| 127 | Menu, | |
| 128 | MenuGroup, | |
| 129 | MenuItem, | |
| 130 | MenuPopup, | |
| 131 | MenuRadioGroup, | |
| 132 | MenuRadioItem, | |
| 133 | MenuSeparator as MenuDivider, | |
| 134 | MenuSub, | |
| 135 | MenuSubPopup, | |
| 136 | MenuSubTrigger, | |
| 137 | MenuShortcut, | |
| 138 | MenuTrigger, | |
| 139 | } from "./ui/menu"; | |
| 140 | import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "./Icons"; | |
| 123 | 141 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 124 | 142 | import { Badge } from "./ui/badge"; |
| 125 | 143 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| ... | ... | @@ -525,6 +543,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 525 | 543 | const [selectedProviderByThread, setSelectedProviderByThread] = useState< |
| 526 | 544 | Partial<Record<ThreadId, ProviderKind>> |
| 527 | 545 | >({}); |
| 546 | const [selectedModelByThread, setSelectedModelByThread] = useState< | |
| 547 | Partial<Record<ThreadId, ModelSlug>> | |
| 548 | >({}); | |
| 528 | 549 | const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); |
| 529 | 550 | const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]); |
| 530 | 551 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); |
| ... | ... | @@ -655,16 +676,37 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 655 | 676 | "codex"; |
| 656 | 677 | const baseThreadModel = resolveModelSlugForProvider( |
| 657 | 678 | selectedProvider, |
| 658 | activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider) ?? DEFAULT_MODEL, | |
| 679 | (activeThread?.id ? selectedModelByThread[activeThread.id] : undefined) ?? | |
| 680 | activeThread?.model ?? | |
| 681 | activeProject?.model ?? | |
| 682 | getDefaultModel(selectedProvider) ?? | |
| 683 | DEFAULT_MODEL, | |
| 659 | 684 | ); |
| 660 | 685 | const selectedModel = resolveModelSlugForProvider(selectedProvider, composerDraft.model ?? baseThreadModel); |
| 661 | 686 | const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING; |
| 662 | 687 | const reasoningOptions = getReasoningOptions(selectedProvider); |
| 663 | 688 | const supportsReasoningEffort = reasoningOptions.length > 0; |
| 689 | const selectedCursorModel = useMemo( | |
| 690 | () => (selectedProvider === "cursor" ? parseCursorModelSelection(selectedModel) : null), | |
| 691 | [selectedModel, selectedProvider], | |
| 692 | ); | |
| 693 | const selectedCursorModelCapabilities = useMemo( | |
| 694 | () => | |
| 695 | selectedCursorModel ? getCursorModelCapabilities(selectedCursorModel.family) : null, | |
| 696 | [selectedCursorModel], | |
| 697 | ); | |
| 698 | const hasSelectedCursorTraits = Boolean( | |
| 699 | selectedCursorModelCapabilities && | |
| 700 | (selectedCursorModelCapabilities.supportsReasoning || | |
| 701 | selectedCursorModelCapabilities.supportsFast || | |
| 702 | selectedCursorModelCapabilities.supportsThinking), | |
| 703 | ); | |
| 704 | const selectedModelForPicker = | |
| 705 | selectedProvider === "cursor" && selectedCursorModel ? selectedCursorModel.family : selectedModel; | |
| 664 | 706 | const searchableModelOptions = useMemo( |
| 665 | 707 | () => |
| 666 | 708 | PROVIDER_OPTIONS.filter((option) => option.available).flatMap((option) => |
| 667 | getModelOptions(option.value).map(({ slug, name }) => ({ | |
| 709 | getProviderModelPickerOptions(option.value).map(({ slug, name }) => ({ | |
| 668 | 710 | provider: option.value, |
| 669 | 711 | providerLabel: option.label, |
| 670 | 712 | slug, |
| ... | ... | @@ -683,6 +725,22 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 683 | 725 | return { ...existing, [activeThread.id]: sessionProvider }; |
| 684 | 726 | }); |
| 685 | 727 | }, [activeThread?.id, sessionProvider]); |
| 728 | useEffect(() => { | |
| 729 | if (!activeThread?.id) return; | |
| 730 | setSelectedModelByThread((existing) => { | |
| 731 | if (existing[activeThread.id]) return existing; | |
| 732 | return { | |
| 733 | ...existing, | |
| 734 | [activeThread.id]: resolveModelSlugForProvider( | |
| 735 | selectedProvider, | |
| 736 | activeThread.model ?? | |
| 737 | activeProject?.model ?? | |
| 738 | getDefaultModel(selectedProvider) ?? | |
| 739 | DEFAULT_MODEL, | |
| 740 | ), | |
| 741 | }; | |
| 742 | }); | |
| 743 | }, [activeProject?.model, activeThread?.id, activeThread?.model, selectedProvider]); | |
| 686 | 744 | const phase = derivePhase(activeThread?.session ?? null); |
| 687 | 745 | const isSendBusy = sendPhase !== "idle"; |
| 688 | 746 | const isPreparingWorktree = sendPhase === "preparing-worktree"; |
| ... | ... | @@ -2261,22 +2319,72 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2261 | 2319 | (provider: ProviderKind, model: ModelSlug) => { |
| 2262 | 2320 | const api = readNativeApi(); |
| 2263 | 2321 | if (!activeThread) return; |
| 2322 | const resolvedModel = | |
| 2323 | provider === "cursor" | |
| 2324 | ? resolveCursorModelFromSelection(parseCursorModelSelection(model)) | |
| 2325 | : resolveModelSlugForProvider(provider, model); | |
| 2264 | 2326 | setSelectedProviderByThread((existing) => ({ |
| 2265 | 2327 | ...existing, |
| 2266 | 2328 | [activeThread.id]: provider, |
| 2267 | 2329 | })); |
| 2330 | setSelectedModelByThread((existing) => ({ | |
| 2331 | ...existing, | |
| 2332 | [activeThread.id]: resolvedModel, | |
| 2333 | })); | |
| 2268 | 2334 | if (api) { |
| 2269 | 2335 | void api.orchestration.dispatchCommand({ |
| 2270 | 2336 | type: "thread.meta.update", |
| 2271 | 2337 | commandId: newCommandId(), |
| 2272 | 2338 | threadId: activeThread.id, |
| 2273 | model: resolveModelSlugForProvider(provider, model), | |
| 2339 | model: resolvedModel, | |
| 2274 | 2340 | }); |
| 2275 | 2341 | } |
| 2276 | 2342 | scheduleComposerFocus(); |
| 2277 | 2343 | }, |
| 2278 | 2344 | [activeThread, scheduleComposerFocus], |
| 2279 | 2345 | ); |
| 2346 | const onCursorReasoningSelect = useCallback( | |
| 2347 | (reasoning: CursorReasoningOption) => { | |
| 2348 | if (selectedProvider !== "cursor") return; | |
| 2349 | const cursorSelection = parseCursorModelSelection(selectedModel); | |
| 2350 | const nextModel = resolveCursorModelFromSelection({ | |
| 2351 | family: cursorSelection.family, | |
| 2352 | reasoning, | |
| 2353 | fast: cursorSelection.fast, | |
| 2354 | thinking: cursorSelection.thinking, | |
| 2355 | }); | |
| 2356 | onProviderModelSelect("cursor", nextModel); | |
| 2357 | }, | |
| 2358 | [onProviderModelSelect, selectedModel, selectedProvider], | |
| 2359 | ); | |
| 2360 | const onCursorFastModeChange = useCallback( | |
| 2361 | (enabled: boolean) => { | |
| 2362 | if (selectedProvider !== "cursor") return; | |
| 2363 | const cursorSelection = parseCursorModelSelection(selectedModel); | |
| 2364 | const nextModel = resolveCursorModelFromSelection({ | |
| 2365 | family: cursorSelection.family, | |
| 2366 | reasoning: cursorSelection.reasoning, | |
| 2367 | fast: enabled, | |
| 2368 | thinking: cursorSelection.thinking, | |
| 2369 | }); | |
| 2370 | onProviderModelSelect("cursor", nextModel); | |
| 2371 | }, | |
| 2372 | [onProviderModelSelect, selectedModel, selectedProvider], | |
| 2373 | ); | |
| 2374 | const onCursorThinkingModeChange = useCallback( | |
| 2375 | (enabled: boolean) => { | |
| 2376 | if (selectedProvider !== "cursor") return; | |
| 2377 | const cursorSelection = parseCursorModelSelection(selectedModel); | |
| 2378 | const nextModel = resolveCursorModelFromSelection({ | |
| 2379 | family: cursorSelection.family, | |
| 2380 | reasoning: cursorSelection.reasoning, | |
| 2381 | fast: cursorSelection.fast, | |
| 2382 | thinking: enabled, | |
| 2383 | }); | |
| 2384 | onProviderModelSelect("cursor", nextModel); | |
| 2385 | }, | |
| 2386 | [onProviderModelSelect, selectedModel, selectedProvider], | |
| 2387 | ); | |
| 2280 | 2388 | const onEffortSelect = useCallback( |
| 2281 | 2389 | (effort: ReasoningEffort) => { |
| 2282 | 2390 | setComposerDraftEffort(threadId, effort); |
| ... | ... | @@ -2716,26 +2824,43 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2716 | 2824 | {/* Provider/model picker */} |
| 2717 | 2825 | <ProviderModelPicker |
| 2718 | 2826 | provider={selectedProvider} |
| 2719 | model={selectedModel} | |
| 2827 | model={selectedModelForPicker} | |
| 2720 | 2828 | onProviderModelChange={onProviderModelSelect} |
| 2721 | 2829 | /> |
| 2722 | 2830 | |
| 2723 | {/* Divider */} | |
| 2724 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> | |
| 2831 | {selectedProvider === "cursor" ? ( | |
| 2832 | <> | |
| 2833 | {hasSelectedCursorTraits && ( | |
| 2834 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> | |
| 2835 | )} | |
| 2725 | 2836 | |
| 2726 | {/* Reasoning effort */} | |
| 2727 | {supportsReasoningEffort ? ( | |
| 2837 | {selectedCursorModel && selectedCursorModelCapabilities && hasSelectedCursorTraits && ( | |
| 2838 | <CursorTraitsPicker | |
| 2839 | selection={selectedCursorModel} | |
| 2840 | capabilities={selectedCursorModelCapabilities} | |
| 2841 | onReasoningChange={onCursorReasoningSelect} | |
| 2842 | onFastModeChange={onCursorFastModeChange} | |
| 2843 | onThinkingModeChange={onCursorThinkingModeChange} | |
| 2844 | /> | |
| 2845 | )} | |
| 2846 | </> | |
| 2847 | ) : supportsReasoningEffort ? ( | |
| 2728 | 2848 | <> |
| 2849 | {/* Divider */} | |
| 2850 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> | |
| 2851 | ||
| 2852 | {/* Reasoning effort */} | |
| 2729 | 2853 | <ReasoningEffortPicker |
| 2730 | 2854 | effort={selectedEffort} |
| 2731 | 2855 | options={reasoningOptions} |
| 2732 | 2856 | onEffortChange={onEffortSelect} |
| 2733 | 2857 | /> |
| 2734 | {/* Divider */} | |
| 2735 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> | |
| 2736 | 2858 | </> |
| 2737 | 2859 | ) : null} |
| 2738 | 2860 | |
| 2861 | {/* Divider */} | |
| 2862 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> | |
| 2863 | ||
| 2739 | 2864 | {/* Runtime mode toggle */} |
| 2740 | 2865 | <Button |
| 2741 | 2866 | variant="ghost" |
| ... | ... | @@ -3868,87 +3993,141 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 3868 | 3993 | ); |
| 3869 | 3994 | }); |
| 3870 | 3995 | |
| 3871 | const PROVIDER_MODEL_GROUPS: ReadonlyArray<{ | |
| 3872 | readonly provider: ProviderKind; | |
| 3873 | readonly label: string; | |
| 3874 | readonly options: ReadonlyArray<{ readonly slug: ModelSlug; readonly name: string }>; | |
| 3875 | }> = PROVIDER_OPTIONS.filter((option) => option.available).map((option) => ({ | |
| 3876 | provider: option.value, | |
| 3877 | label: option.label, | |
| 3878 | options: getModelOptions(option.value), | |
| 3879 | })); | |
| 3996 | const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => option.available); | |
| 3997 | const COMING_SOON_PROVIDER_OPTIONS = [ | |
| 3998 | { id: "opencode", label: "OpenCode", icon: OpenCodeIcon }, | |
| 3999 | { id: "gemini", label: "Gemini", icon: Gemini }, | |
| 4000 | ] as const; | |
| 4001 | ||
| 4002 | function getProviderModelPickerOptions(provider: ProviderKind) { | |
| 4003 | if (provider === "cursor") { | |
| 4004 | return getCursorModelFamilyOptions(); | |
| 4005 | } | |
| 4006 | return getModelOptions(provider); | |
| 4007 | } | |
| 3880 | 4008 | |
| 3881 | 4009 | const PROVIDER_ICON_BY_PROVIDER: Record<ProviderKind, Icon> = { |
| 3882 | 4010 | codex: OpenAI, |
| 3883 | 4011 | claudeCode: ClaudeAI, |
| 4012 | cursor: CursorIcon, | |
| 3884 | 4013 | }; |
| 3885 | 4014 | |
| 4015 | function resolveModelForProviderPicker( | |
| 4016 | provider: ProviderKind, | |
| 4017 | value: string, | |
| 4018 | ): ModelSlug | null { | |
| 4019 | const trimmedValue = value.trim(); | |
| 4020 | if (!trimmedValue) { | |
| 4021 | return null; | |
| 4022 | } | |
| 4023 | ||
| 4024 | const options = getProviderModelPickerOptions(provider); | |
| 4025 | const direct = options.find((option) => option.slug === trimmedValue); | |
| 4026 | if (direct) { | |
| 4027 | return direct.slug; | |
| 4028 | } | |
| 4029 | ||
| 4030 | const byName = options.find((option) => option.name.toLowerCase() === trimmedValue.toLowerCase()); | |
| 4031 | if (byName) { | |
| 4032 | return byName.slug; | |
| 4033 | } | |
| 4034 | ||
| 4035 | const normalized = normalizeModelSlug(trimmedValue, provider); | |
| 4036 | if (!normalized) { | |
| 4037 | return null; | |
| 4038 | } | |
| 4039 | ||
| 4040 | const resolved = options.find((option) => option.slug === normalized); | |
| 4041 | if (resolved) { | |
| 4042 | return resolved.slug; | |
| 4043 | } | |
| 4044 | ||
| 4045 | if (provider === "cursor") { | |
| 4046 | return parseCursorModelSelection(normalized).family; | |
| 4047 | } | |
| 4048 | ||
| 4049 | return null; | |
| 4050 | } | |
| 4051 | ||
| 3886 | 4052 | const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 3887 | 4053 | provider: ProviderKind; |
| 3888 | 4054 | model: ModelSlug; |
| 3889 | 4055 | onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; |
| 3890 | 4056 | }) { |
| 3891 | const items = PROVIDER_MODEL_GROUPS.flatMap(({ provider, label, options }) => | |
| 3892 | options.map(({ slug, name }) => ({ | |
| 3893 | label: `${label} · ${name}`, | |
| 3894 | value: `${provider}:${slug}`, | |
| 3895 | })), | |
| 3896 | ); | |
| 4057 | const [isMenuOpen, setIsMenuOpen] = useState(false); | |
| 4058 | const selectedProviderOptions = getProviderModelPickerOptions(props.provider); | |
| 3897 | 4059 | const selectedModelLabel = |
| 3898 | PROVIDER_MODEL_GROUPS.find((group) => group.provider === props.provider)?.options.find( | |
| 3899 | (option) => option.slug === props.model, | |
| 3900 | )?.name ?? props.model; | |
| 4060 | selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; | |
| 3901 | 4061 | const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; |
| 3902 | 4062 | |
| 3903 | 4063 | return ( |
| 3904 | <Select | |
| 3905 | items={items} | |
| 3906 | value={`${props.provider}:${props.model}`} | |
| 3907 | onValueChange={(value) => | |
| 3908 | value | |
| 3909 | ? (() => { | |
| 3910 | if (value.startsWith("codex:")) { | |
| 3911 | props.onProviderModelChange( | |
| 3912 | "codex", | |
| 3913 | resolveModelSlugForProvider("codex", value.slice("codex:".length)), | |
| 3914 | ); | |
| 3915 | return; | |
| 3916 | } | |
| 3917 | if (value.startsWith("claudeCode:")) { | |
| 3918 | props.onProviderModelChange( | |
| 3919 | "claudeCode", | |
| 3920 | resolveModelSlugForProvider("claudeCode", value.slice("claudeCode:".length)), | |
| 3921 | ); | |
| 3922 | } | |
| 3923 | })() | |
| 3924 | : undefined | |
| 3925 | } | |
| 3926 | > | |
| 3927 | <SelectTrigger size="sm" variant="ghost"> | |
| 4064 | <Menu open={isMenuOpen} onOpenChange={setIsMenuOpen}> | |
| 4065 | <MenuTrigger | |
| 4066 | render={<Button size="sm" variant="ghost" className="shrink-0 whitespace-nowrap px-2 sm:px-3" />} | |
| 4067 | > | |
| 3928 | 4068 | <span className="flex min-w-0 items-center gap-2"> |
| 3929 | 4069 | <ProviderIcon |
| 3930 | 4070 | aria-hidden="true" |
| 3931 | 4071 | className={cn( |
| 3932 | 4072 | "size-4 shrink-0", |
| 3933 | props.provider === "claudeCode" ? "" : "text-foreground/85", | |
| 4073 | props.provider === "claudeCode" ? "" : "text-foreground/80", | |
| 3934 | 4074 | )} |
| 3935 | 4075 | /> |
| 3936 | 4076 | <span className="truncate">{selectedModelLabel}</span> |
| 4077 | <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" /> | |
| 3937 | 4078 | </span> |
| 3938 | </SelectTrigger> | |
| 3939 | <SelectPopup alignItemWithTrigger={false}> | |
| 3940 | {PROVIDER_MODEL_GROUPS.map(({ provider, label, options }) => ( | |
| 3941 | <SelectGroup key={provider}> | |
| 3942 | <SelectGroupLabel>{label}</SelectGroupLabel> | |
| 3943 | {options.map(({ slug, name }) => ( | |
| 3944 | <SelectItem key={`${provider}:${slug}`} value={`${provider}:${slug}`}> | |
| 3945 | {name} | |
| 3946 | </SelectItem> | |
| 3947 | ))} | |
| 3948 | </SelectGroup> | |
| 3949 | ))} | |
| 3950 | </SelectPopup> | |
| 3951 | </Select> | |
| 4079 | </MenuTrigger> | |
| 4080 | <MenuPopup align="start"> | |
| 4081 | {AVAILABLE_PROVIDER_OPTIONS.map((option) => { | |
| 4082 | const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; | |
| 4083 | return ( | |
| 4084 | <MenuSub key={option.value}> | |
| 4085 | <MenuSubTrigger> | |
| 4086 | <OptionIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/85" /> | |
| 4087 | {option.label} | |
| 4088 | </MenuSubTrigger> | |
| 4089 | <MenuSubPopup className="[--available-height:min(24rem,70vh)]"> | |
| 4090 | <MenuGroup> | |
| 4091 | <MenuRadioGroup | |
| 4092 | value={props.provider === option.value ? props.model : ""} | |
| 4093 | onValueChange={(value) => { | |
| 4094 | if (!value) return; | |
| 4095 | const resolvedModel = resolveModelForProviderPicker(option.value, value); | |
| 4096 | if (!resolvedModel) return; | |
| 4097 | props.onProviderModelChange(option.value, resolvedModel); | |
| 4098 | setIsMenuOpen(false); | |
| 4099 | }} | |
| 4100 | > | |
| 4101 | {getProviderModelPickerOptions(option.value).map((modelOption) => ( | |
| 4102 | <MenuRadioItem | |
| 4103 | key={`${option.value}:${modelOption.slug}`} | |
| 4104 | value={modelOption.slug} | |
| 4105 | onClick={() => setIsMenuOpen(false)} | |
| 4106 | > | |
| 4107 | {modelOption.name} | |
| 4108 | </MenuRadioItem> | |
| 4109 | ))} | |
| 4110 | </MenuRadioGroup> | |
| 4111 | </MenuGroup> | |
| 4112 | </MenuSubPopup> | |
| 4113 | </MenuSub> | |
| 4114 | ); | |
| 4115 | })} | |
| 4116 | <MenuDivider /> | |
| 4117 | {COMING_SOON_PROVIDER_OPTIONS.map((option) => { | |
| 4118 | const OptionIcon = option.icon; | |
| 4119 | return ( | |
| 4120 | <MenuItem key={option.id} disabled> | |
| 4121 | <OptionIcon aria-hidden="true" className="size-4 shrink-0 opacity-80" /> | |
| 4122 | <span>{option.label}</span> | |
| 4123 | <span className="ms-auto text-[11px] text-muted-foreground/80 uppercase tracking-[0.08em]"> | |
| 4124 | Coming soon | |
| 4125 | </span> | |
| 4126 | </MenuItem> | |
| 4127 | ); | |
| 4128 | })} | |
| 4129 | </MenuPopup> | |
| 4130 | </Menu> | |
| 3952 | 4131 | ); |
| 3953 | 4132 | }); |
| 3954 | 4133 | |
| ... | ... | @@ -3977,6 +4156,90 @@ const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: { |
| 3977 | 4156 | ); |
| 3978 | 4157 | }); |
| 3979 | 4158 | |
| 4159 | const CursorTraitsPicker = memo(function CursorTraitsPicker(props: { | |
| 4160 | selection: ReturnType<typeof parseCursorModelSelection>; | |
| 4161 | capabilities: ReturnType<typeof getCursorModelCapabilities>; | |
| 4162 | onReasoningChange: (reasoning: CursorReasoningOption) => void; | |
| 4163 | onFastModeChange: (enabled: boolean) => void; | |
| 4164 | onThinkingModeChange: (enabled: boolean) => void; | |
| 4165 | }) { | |
| 4166 | const reasoningLabelByOption: Record<CursorReasoningOption, string> = { | |
| 4167 | low: "Low", | |
| 4168 | normal: "Normal", | |
| 4169 | high: "High", | |
| 4170 | xhigh: "XHigh", | |
| 4171 | }; | |
| 4172 | const traitSummary = [ | |
| 4173 | ...(props.capabilities.supportsReasoning | |
| 4174 | ? [reasoningLabelByOption[props.selection.reasoning]] | |
| 4175 | : []), | |
| 4176 | ...(props.capabilities.supportsFast && props.selection.fast ? ["Fast"] : []), | |
| 4177 | ...(props.capabilities.supportsThinking && props.selection.thinking ? ["Thinking"] : []), | |
| 4178 | ]; | |
| 4179 | const triggerLabel = traitSummary.length > 0 ? traitSummary.join(" · ") : "Traits"; | |
| 4180 | ||
| 4181 | return ( | |
| 4182 | <Menu> | |
| 4183 | <MenuTrigger | |
| 4184 | render={<Button size="sm" variant="ghost" className="shrink-0 whitespace-nowrap px-2 sm:px-3" />} | |
| 4185 | > | |
| 4186 | <span>{triggerLabel}</span> | |
| 4187 | <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" /> | |
| 4188 | </MenuTrigger> | |
| 4189 | <MenuPopup align="start"> | |
| 4190 | {props.capabilities.supportsReasoning && ( | |
| 4191 | <MenuGroup> | |
| 4192 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Reasoning</div> | |
| 4193 | <MenuRadioGroup | |
| 4194 | value={props.selection.reasoning} | |
| 4195 | onValueChange={(value) => { | |
| 4196 | if (!value) return; | |
| 4197 | const nextReasoning = CURSOR_REASONING_OPTIONS.find((option) => option === value); | |
| 4198 | if (!nextReasoning) return; | |
| 4199 | props.onReasoningChange(nextReasoning); | |
| 4200 | }} | |
| 4201 | > | |
| 4202 | {CURSOR_REASONING_OPTIONS.map((reasoning) => ( | |
| 4203 | <MenuRadioItem key={reasoning} value={reasoning}> | |
| 4204 | {reasoning} | |
| 4205 | {reasoning === "normal" ? " (default)" : ""} | |
| 4206 | </MenuRadioItem> | |
| 4207 | ))} | |
| 4208 | </MenuRadioGroup> | |
| 4209 | </MenuGroup> | |
| 4210 | )} | |
| 4211 | {props.capabilities.supportsReasoning && | |
| 4212 | (props.capabilities.supportsFast || props.capabilities.supportsThinking) && <MenuDivider />} | |
| 4213 | {props.capabilities.supportsFast && ( | |
| 4214 | <MenuGroup> | |
| 4215 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div> | |
| 4216 | <MenuRadioGroup | |
| 4217 | value={props.selection.fast ? "on" : "off"} | |
| 4218 | onValueChange={(value) => props.onFastModeChange(value === "on")} | |
| 4219 | > | |
| 4220 | <MenuRadioItem value="off">off</MenuRadioItem> | |
| 4221 | <MenuRadioItem value="on">on</MenuRadioItem> | |
| 4222 | </MenuRadioGroup> | |
| 4223 | </MenuGroup> | |
| 4224 | )} | |
| 4225 | {props.capabilities.supportsFast && props.capabilities.supportsThinking && <MenuDivider />} | |
| 4226 | {props.capabilities.supportsThinking && ( | |
| 4227 | <MenuGroup> | |
| 4228 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Thinking</div> | |
| 4229 | <MenuRadioGroup | |
| 4230 | value={props.selection.thinking ? "on" : "off"} | |
| 4231 | onValueChange={(value) => props.onThinkingModeChange(value === "on")} | |
| 4232 | > | |
| 4233 | <MenuRadioItem value="off">off</MenuRadioItem> | |
| 4234 | <MenuRadioItem value="on">on</MenuRadioItem> | |
| 4235 | </MenuRadioGroup> | |
| 4236 | </MenuGroup> | |
| 4237 | )} | |
| 4238 | </MenuPopup> | |
| 4239 | </Menu> | |
| 4240 | ); | |
| 4241 | }); | |
| 4242 | ||
| 3980 | 4243 | const OpenInPicker = memo(function OpenInPicker({ |
| 3981 | 4244 | keybindings, |
| 3982 | 4245 | availableEditors, |
apps/web/src/components/Icons.tsx+182| ... | ... | @@ -34,3 +34,185 @@ export const ClaudeAI: Icon = (props) => ( |
| 34 | 34 | /> |
| 35 | 35 | </svg> |
| 36 | 36 | ); |
| 37 | ||
| 38 | export const Gemini: Icon = (props) => ( | |
| 39 | <svg {...props} viewBox="0 0 296 298" fill="none"> | |
| 40 | <mask | |
| 41 | id="gemini__a" | |
| 42 | width="296" | |
| 43 | height="298" | |
| 44 | x="0" | |
| 45 | y="0" | |
| 46 | maskUnits="userSpaceOnUse" | |
| 47 | style={{ maskType: "alpha" }} | |
| 48 | > | |
| 49 | <path | |
| 50 | fill="#3186FF" | |
| 51 | d="M141.201 4.886c2.282-6.17 11.042-6.071 13.184.148l5.985 17.37a184.004 184.004 0 0 0 111.257 113.049l19.304 6.997c6.143 2.227 6.156 10.91.02 13.155l-19.35 7.082a184.001 184.001 0 0 0-109.495 109.385l-7.573 20.629c-2.241 6.105-10.869 6.121-13.133.025l-7.908-21.296a184 184 0 0 0-109.02-108.658l-19.698-7.239c-6.102-2.243-6.118-10.867-.025-13.132l20.083-7.467A183.998 183.998 0 0 0 133.291 26.28l7.91-21.394Z" | |
| 52 | /> | |
| 53 | </mask> | |
| 54 | <g mask="url(#gemini__a)"> | |
| 55 | <g filter="url(#gemini__b)"> | |
| 56 | <ellipse cx="163" cy="149" fill="#3689FF" rx="196" ry="159" /> | |
| 57 | </g> | |
| 58 | <g filter="url(#gemini__c)"> | |
| 59 | <ellipse cx="33.5" cy="142.5" fill="#F6C013" rx="68.5" ry="72.5" /> | |
| 60 | </g> | |
| 61 | <g filter="url(#gemini__d)"> | |
| 62 | <ellipse cx="19.5" cy="148.5" fill="#F6C013" rx="68.5" ry="72.5" /> | |
| 63 | </g> | |
| 64 | <g filter="url(#gemini__e)"> | |
| 65 | <path | |
| 66 | fill="#FA4340" | |
| 67 | d="M194 10.5C172 82.5 65.5 134.333 22.5 135L144-66l50 76.5Z" | |
| 68 | /> | |
| 69 | </g> | |
| 70 | <g filter="url(#gemini__f)"> | |
| 71 | <path | |
| 72 | fill="#FA4340" | |
| 73 | d="M190.5-12.5C168.5 59.5 62 111.333 19 112L140.5-89l50 76.5Z" | |
| 74 | /> | |
| 75 | </g> | |
| 76 | <g filter="url(#gemini__g)"> | |
| 77 | <path | |
| 78 | fill="#14BB69" | |
| 79 | d="M194.5 279.5C172.5 207.5 66 155.667 23 155l121.5 201 50-76.5Z" | |
| 80 | /> | |
| 81 | </g> | |
| 82 | <g filter="url(#gemini__h)"> | |
| 83 | <path | |
| 84 | fill="#14BB69" | |
| 85 | d="M196.5 320.5C174.5 248.5 68 196.667 25 196l121.5 201 50-76.5Z" | |
| 86 | /> | |
| 87 | </g> | |
| 88 | </g> | |
| 89 | <defs> | |
| 90 | <filter | |
| 91 | id="gemini__b" | |
| 92 | width="464" | |
| 93 | height="390" | |
| 94 | x="-69" | |
| 95 | y="-46" | |
| 96 | colorInterpolationFilters="sRGB" | |
| 97 | filterUnits="userSpaceOnUse" | |
| 98 | > | |
| 99 | <feFlood floodOpacity="0" result="BackgroundImageFix" /> | |
| 100 | <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" /> | |
| 101 | <feGaussianBlur | |
| 102 | result="effect1_foregroundBlur_69_17998" | |
| 103 | stdDeviation="18" | |
| 104 | /> | |
| 105 | </filter> | |
| 106 | <filter | |
| 107 | id="gemini__c" | |
| 108 | width="265" | |
| 109 | height="273" | |
| 110 | x="-99" | |
| 111 | y="6" | |
| 112 | colorInterpolationFilters="sRGB" | |
| 113 | filterUnits="userSpaceOnUse" | |
| 114 | > | |
| 115 | <feFlood floodOpacity="0" result="BackgroundImageFix" /> | |
| 116 | <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" /> | |
| 117 | <feGaussianBlur | |
| 118 | result="effect1_foregroundBlur_69_17998" | |
| 119 | stdDeviation="32" | |
| 120 | /> | |
| 121 | </filter> | |
| 122 | <filter | |
| 123 | id="gemini__d" | |
| 124 | width="265" | |
| 125 | height="273" | |
| 126 | x="-113" | |
| 127 | y="12" | |
| 128 | colorInterpolationFilters="sRGB" | |
| 129 | filterUnits="userSpaceOnUse" | |
| 130 | > | |
| 131 | <feFlood floodOpacity="0" result="BackgroundImageFix" /> | |
| 132 | <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" /> | |
| 133 | <feGaussianBlur | |
| 134 | result="effect1_foregroundBlur_69_17998" | |
| 135 | stdDeviation="32" | |
| 136 | /> | |
| 137 | </filter> | |
| 138 | <filter | |
| 139 | id="gemini__e" | |
| 140 | width="299.5" | |
| 141 | height="329" | |
| 142 | x="-41.5" | |
| 143 | y="-130" | |
| 144 | colorInterpolationFilters="sRGB" | |
| 145 | filterUnits="userSpaceOnUse" | |
| 146 | > | |
| 147 | <feFlood floodOpacity="0" result="BackgroundImageFix" /> | |
| 148 | <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" /> | |
| 149 | <feGaussianBlur | |
| 150 | result="effect1_foregroundBlur_69_17998" | |
| 151 | stdDeviation="32" | |
| 152 | /> | |
| 153 | </filter> | |
| 154 | <filter | |
| 155 | id="gemini__f" | |
| 156 | width="299.5" | |
| 157 | height="329" | |
| 158 | x="-45" | |
| 159 | y="-153" | |
| 160 | colorInterpolationFilters="sRGB" | |
| 161 | filterUnits="userSpaceOnUse" | |
| 162 | > | |
| 163 | <feFlood floodOpacity="0" result="BackgroundImageFix" /> | |
| 164 | <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" /> | |
| 165 | <feGaussianBlur | |
| 166 | result="effect1_foregroundBlur_69_17998" | |
| 167 | stdDeviation="32" | |
| 168 | /> | |
| 169 | </filter> | |
| 170 | <filter | |
| 171 | id="gemini__g" | |
| 172 | width="299.5" | |
| 173 | height="329" | |
| 174 | x="-41" | |
| 175 | y="91" | |
| 176 | colorInterpolationFilters="sRGB" | |
| 177 | filterUnits="userSpaceOnUse" | |
| 178 | > | |
| 179 | <feFlood floodOpacity="0" result="BackgroundImageFix" /> | |
| 180 | <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" /> | |
| 181 | <feGaussianBlur | |
| 182 | result="effect1_foregroundBlur_69_17998" | |
| 183 | stdDeviation="32" | |
| 184 | /> | |
| 185 | </filter> | |
| 186 | <filter | |
| 187 | id="gemini__h" | |
| 188 | width="299.5" | |
| 189 | height="329" | |
| 190 | x="-39" | |
| 191 | y="132" | |
| 192 | colorInterpolationFilters="sRGB" | |
| 193 | filterUnits="userSpaceOnUse" | |
| 194 | > | |
| 195 | <feFlood floodOpacity="0" result="BackgroundImageFix" /> | |
| 196 | <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" /> | |
| 197 | <feGaussianBlur | |
| 198 | result="effect1_foregroundBlur_69_17998" | |
| 199 | stdDeviation="32" | |
| 200 | /> | |
| 201 | </filter> | |
| 202 | </defs> | |
| 203 | </svg> | |
| 204 | ); | |
| 205 | ||
| 206 | export const OpenCodeIcon: Icon = (props) => ( | |
| 207 | <svg {...props} viewBox="0 0 32 40" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| 208 | <g clipPath="url(#opencode__clip0_1311_94969)"> | |
| 209 | <path d="M24 32H8V16H24V32Z" fill="#BCBBBB" /> | |
| 210 | <path d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill="#211E1E" /> | |
| 211 | </g> | |
| 212 | <defs> | |
| 213 | <clipPath id="opencode__clip0_1311_94969"> | |
| 214 | <rect width="32" height="40" fill="white" /> | |
| 215 | </clipPath> | |
| 216 | </defs> | |
| 217 | </svg> | |
| 218 | ); |
apps/web/src/session-logic.test.ts+9| ... | ... | @@ -235,4 +235,13 @@ describe("PROVIDER_OPTIONS", () => { |
| 235 | 235 | available: true, |
| 236 | 236 | }); |
| 237 | 237 | }); |
| 238 | ||
| 239 | it("marks cursor as available", () => { | |
| 240 | const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor"); | |
| 241 | expect(cursor).toEqual({ | |
| 242 | value: "cursor", | |
| 243 | label: "Cursor Agent", | |
| 244 | available: true, | |
| 245 | }); | |
| 246 | }); | |
| 238 | 247 | }); |
apps/web/src/session-logic.ts+1| ... | ... | @@ -15,6 +15,7 @@ export const PROVIDER_OPTIONS: Array<{ |
| 15 | 15 | }> = [ |
| 16 | 16 | { value: "codex", label: "Codex", available: true }, |
| 17 | 17 | { value: "claudeCode", label: "Claude Code", available: true }, |
| 18 | { value: "cursor", label: "Cursor Agent", available: true }, | |
| 18 | 19 | ]; |
| 19 | 20 | |
| 20 | 21 | export interface WorkLogEntry { |
apps/web/src/store.test.ts+26| ... | ... | @@ -161,4 +161,30 @@ describe("store read model sync", () => { |
| 161 | 161 | |
| 162 | 162 | expect(next.threads[0]?.model).toBe("claude-sonnet-4-6"); |
| 163 | 163 | }); |
| 164 | ||
| 165 | it("resolves cursor aliases when session provider is cursor", () => { | |
| 166 | const initialState = makeState(makeThread()); | |
| 167 | const readModel = makeReadModel( | |
| 168 | makeReadModelThread({ | |
| 169 | model: "composer", | |
| 170 | session: { | |
| 171 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 172 | status: "ready", | |
| 173 | providerName: "cursor", | |
| 174 | providerSessionId: null, | |
| 175 | providerThreadId: null, | |
| 176 | approvalPolicy: "on-request", | |
| 177 | sandboxMode: "workspace-write", | |
| 178 | activeTurnId: null, | |
| 179 | lastError: null, | |
| 180 | updatedAt: "2026-02-27T00:00:00.000Z", | |
| 181 | }, | |
| 182 | }), | |
| 183 | ); | |
| 184 | ||
| 185 | const next = syncServerReadModel(initialState, readModel); | |
| 186 | ||
| 187 | expect(next.threads[0]?.model).toBe("composer-1.5"); | |
| 188 | expect(next.threads[0]?.session?.provider).toBe("cursor"); | |
| 189 | }); | |
| 164 | 190 | }); |
apps/web/src/store.ts+29-4| ... | ... | @@ -3,6 +3,7 @@ import { |
| 3 | 3 | DEFAULT_MODEL, |
| 4 | 4 | getModelOptions, |
| 5 | 5 | normalizeModelSlug, |
| 6 | type ProviderKind, | |
| 6 | 7 | ProviderSessionId, |
| 7 | 8 | ThreadId, |
| 8 | 9 | type OrchestrationReadModel, |
| ... | ... | @@ -156,20 +157,37 @@ function toLegacySessionStatus( |
| 156 | 157 | } |
| 157 | 158 | } |
| 158 | 159 | |
| 159 | function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" { | |
| 160 | return providerName === "claudeCode" ? "claudeCode" : "codex"; | |
| 160 | function toLegacyProvider(providerName: string | null): ProviderKind { | |
| 161 | if (providerName === "codex" || providerName === "claudeCode" || providerName === "cursor") { | |
| 162 | return providerName; | |
| 163 | } | |
| 164 | return "codex"; | |
| 161 | 165 | } |
| 162 | 166 | |
| 163 | 167 | const CODEX_MODEL_SLUGS = new Set(getModelOptions("codex").map((option) => option.slug)); |
| 164 | 168 | const CLAUDE_MODEL_SLUGS = new Set(getModelOptions("claudeCode").map((option) => option.slug)); |
| 169 | const CURSOR_MODEL_SLUGS = new Set(getModelOptions("cursor").map((option) => option.slug)); | |
| 170 | const CURSOR_DISTINCT_MODEL_SLUGS = new Set( | |
| 171 | [...CURSOR_MODEL_SLUGS].filter( | |
| 172 | (slug) => !CODEX_MODEL_SLUGS.has(slug) && !CLAUDE_MODEL_SLUGS.has(slug), | |
| 173 | ), | |
| 174 | ); | |
| 165 | 175 | |
| 166 | 176 | function inferProviderForThreadModel(input: { |
| 167 | 177 | readonly model: string; |
| 168 | 178 | readonly sessionProviderName: string | null; |
| 169 | }): "codex" | "claudeCode" { | |
| 170 | if (input.sessionProviderName === "codex" || input.sessionProviderName === "claudeCode") { | |
| 179 | }): ProviderKind { | |
| 180 | if ( | |
| 181 | input.sessionProviderName === "codex" || | |
| 182 | input.sessionProviderName === "claudeCode" || | |
| 183 | input.sessionProviderName === "cursor" | |
| 184 | ) { | |
| 171 | 185 | return input.sessionProviderName; |
| 172 | 186 | } |
| 187 | const normalizedCursor = normalizeModelSlug(input.model, "cursor"); | |
| 188 | if (normalizedCursor && CURSOR_DISTINCT_MODEL_SLUGS.has(normalizedCursor)) { | |
| 189 | return "cursor"; | |
| 190 | } | |
| 173 | 191 | const normalizedClaude = normalizeModelSlug(input.model, "claudeCode"); |
| 174 | 192 | if (normalizedClaude && CLAUDE_MODEL_SLUGS.has(normalizedClaude)) { |
| 175 | 193 | return "claudeCode"; |
| ... | ... | @@ -178,6 +196,13 @@ function inferProviderForThreadModel(input: { |
| 178 | 196 | if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) { |
| 179 | 197 | return "codex"; |
| 180 | 198 | } |
| 199 | if ( | |
| 200 | input.model.trim().startsWith("composer-") || | |
| 201 | input.model.trim().startsWith("gemini-") || | |
| 202 | input.model.trim().endsWith("-thinking") | |
| 203 | ) { | |
| 204 | return "cursor"; | |
| 205 | } | |
| 181 | 206 | return input.model.trim().startsWith("claude-") ? "claudeCode" : "codex"; |
| 182 | 207 | } |
| 183 | 208 |
packages/contracts/src/model.test.ts+73| ... | ... | @@ -1,15 +1,20 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | CURSOR_MODEL_FAMILY_OPTIONS, | |
| 5 | CURSOR_REASONING_OPTIONS, | |
| 4 | 6 | DEFAULT_MODEL, |
| 5 | 7 | DEFAULT_MODEL_BY_PROVIDER, |
| 6 | 8 | MODEL_OPTIONS, |
| 7 | 9 | MODEL_OPTIONS_BY_PROVIDER, |
| 8 | 10 | getDefaultModel, |
| 11 | getCursorModelFamilyOptions, | |
| 9 | 12 | getModelOptions, |
| 10 | 13 | getReasoningOptions, |
| 11 | 14 | normalizeModelSlug, |
| 12 | 15 | REASONING_OPTIONS, |
| 16 | parseCursorModelSelection, | |
| 17 | resolveCursorModelFromSelection, | |
| 13 | 18 | resolveModelSlug, |
| 14 | 19 | resolveModelSlugForProvider, |
| 15 | 20 | } from "./model"; |
| ... | ... | @@ -38,6 +43,14 @@ describe("normalizeModelSlug", () => { |
| 38 | 43 | expect(normalizeModelSlug("claude-haiku-4-5-20251001", "claudeCode")).toBe( |
| 39 | 44 | "claude-haiku-4-5", |
| 40 | 45 | ); |
| 46 | expect(normalizeModelSlug("composer", "cursor")).toBe("composer-1.5"); | |
| 47 | expect(normalizeModelSlug("gpt-5.3-codex-spark", "cursor")).toBe( | |
| 48 | "gpt-5.3-codex-spark-preview", | |
| 49 | ); | |
| 50 | expect(normalizeModelSlug("gemini-3.1", "cursor")).toBe("gemini-3.1-pro"); | |
| 51 | expect(normalizeModelSlug("claude-4.6-sonnet-thinking", "cursor")).toBe( | |
| 52 | "sonnet-4.6-thinking", | |
| 53 | ); | |
| 41 | 54 | }); |
| 42 | 55 | }); |
| 43 | 56 | |
| ... | ... | @@ -66,12 +79,72 @@ describe("resolveModelSlug", () => { |
| 66 | 79 | expect(resolveModelSlugForProvider("claudeCode", "gpt-5.3-codex")).toBe( |
| 67 | 80 | DEFAULT_MODEL_BY_PROVIDER.claudeCode, |
| 68 | 81 | ); |
| 82 | expect(resolveModelSlugForProvider("cursor", undefined)).toBe( | |
| 83 | DEFAULT_MODEL_BY_PROVIDER.cursor, | |
| 84 | ); | |
| 85 | expect(resolveModelSlugForProvider("cursor", "composer")).toBe("composer-1.5"); | |
| 86 | expect(resolveModelSlugForProvider("cursor", "gpt-5.3-codex-high-fast")).toBe( | |
| 87 | "gpt-5.3-codex-high-fast", | |
| 88 | ); | |
| 89 | expect(resolveModelSlugForProvider("cursor", "claude-sonnet-4-6")).toBe( | |
| 90 | DEFAULT_MODEL_BY_PROVIDER.cursor, | |
| 91 | ); | |
| 69 | 92 | }); |
| 70 | 93 | |
| 71 | 94 | it("keeps codex defaults for backward compatibility", () => { |
| 72 | 95 | expect(getDefaultModel()).toBe(DEFAULT_MODEL); |
| 73 | 96 | expect(getModelOptions()).toEqual(MODEL_OPTIONS); |
| 74 | 97 | expect(getModelOptions("claudeCode")).toEqual(MODEL_OPTIONS_BY_PROVIDER.claudeCode); |
| 98 | expect(getModelOptions("cursor")).toEqual(MODEL_OPTIONS_BY_PROVIDER.cursor); | |
| 99 | expect(getCursorModelFamilyOptions()).toEqual(CURSOR_MODEL_FAMILY_OPTIONS); | |
| 100 | }); | |
| 101 | }); | |
| 102 | ||
| 103 | describe("cursor model selection", () => { | |
| 104 | it("includes the expected cursor reasoning levels and families", () => { | |
| 105 | expect(CURSOR_REASONING_OPTIONS).toEqual(["low", "normal", "high", "xhigh"]); | |
| 106 | expect(getCursorModelFamilyOptions().map((option) => option.slug)).toContain("gpt-5.3-codex"); | |
| 107 | expect(getCursorModelFamilyOptions().map((option) => option.slug)).toContain("opus-4.6"); | |
| 108 | }); | |
| 109 | ||
| 110 | it("parses codex reasoning and fast mode variants", () => { | |
| 111 | expect(parseCursorModelSelection("gpt-5.3-codex-high-fast")).toEqual({ | |
| 112 | family: "gpt-5.3-codex", | |
| 113 | reasoning: "high", | |
| 114 | fast: true, | |
| 115 | thinking: false, | |
| 116 | }); | |
| 117 | expect(parseCursorModelSelection("gpt-5.2-codex")).toEqual({ | |
| 118 | family: "gpt-5.2-codex", | |
| 119 | reasoning: "normal", | |
| 120 | fast: false, | |
| 121 | thinking: false, | |
| 122 | }); | |
| 123 | }); | |
| 124 | ||
| 125 | it("parses and resolves thinking variants", () => { | |
| 126 | expect(parseCursorModelSelection("sonnet-4.6-thinking")).toEqual({ | |
| 127 | family: "sonnet-4.6", | |
| 128 | reasoning: "normal", | |
| 129 | fast: false, | |
| 130 | thinking: true, | |
| 131 | }); | |
| 132 | expect( | |
| 133 | resolveCursorModelFromSelection({ | |
| 134 | family: "sonnet-4.6", | |
| 135 | thinking: true, | |
| 136 | }), | |
| 137 | ).toBe("sonnet-4.6-thinking"); | |
| 138 | }); | |
| 139 | ||
| 140 | it("resolves codex family selections into concrete model ids", () => { | |
| 141 | expect( | |
| 142 | resolveCursorModelFromSelection({ | |
| 143 | family: "gpt-5.2-codex", | |
| 144 | reasoning: "xhigh", | |
| 145 | fast: true, | |
| 146 | }), | |
| 147 | ).toBe("gpt-5.2-codex-xhigh-fast"); | |
| 75 | 148 | }); |
| 76 | 149 | }); |
| 77 | 150 |
packages/contracts/src/model.ts+393-5| ... | ... | @@ -5,6 +5,39 @@ type ModelOption = { |
| 5 | 5 | readonly name: string; |
| 6 | 6 | }; |
| 7 | 7 | |
| 8 | export const CURSOR_REASONING_OPTIONS = ["low", "normal", "high", "xhigh"] as const; | |
| 9 | export type CursorReasoningOption = (typeof CURSOR_REASONING_OPTIONS)[number]; | |
| 10 | ||
| 11 | type CursorModelFamilyOption = { | |
| 12 | readonly slug: string; | |
| 13 | readonly name: string; | |
| 14 | }; | |
| 15 | ||
| 16 | export const CURSOR_MODEL_FAMILY_OPTIONS = [ | |
| 17 | { slug: "auto", name: "Auto" }, | |
| 18 | { slug: "composer-1.5", name: "Composer 1.5" }, | |
| 19 | { slug: "composer-1", name: "Composer 1" }, | |
| 20 | { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, | |
| 21 | { slug: "gpt-5.3-codex-spark-preview", name: "GPT-5.3 Codex Spark" }, | |
| 22 | { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, | |
| 23 | { slug: "gpt-5.2", name: "GPT-5.2" }, | |
| 24 | { slug: "gpt-5.2-high", name: "GPT-5.2 High" }, | |
| 25 | { slug: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" }, | |
| 26 | { slug: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" }, | |
| 27 | { slug: "gpt-5.1-high", name: "GPT-5.1 High" }, | |
| 28 | { slug: "opus-4.6", name: "Claude 4.6 Opus" }, | |
| 29 | { slug: "opus-4.5", name: "Claude 4.5 Opus" }, | |
| 30 | { slug: "sonnet-4.6", name: "Claude 4.6 Sonnet" }, | |
| 31 | { slug: "sonnet-4.5", name: "Claude 4.5 Sonnet" }, | |
| 32 | { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, | |
| 33 | { slug: "gemini-3-pro", name: "Gemini 3 Pro" }, | |
| 34 | { slug: "gemini-3-flash", name: "Gemini 3 Flash" }, | |
| 35 | { slug: "grok", name: "Grok" }, | |
| 36 | { slug: "kimi-k2.5", name: "Kimi K2.5" }, | |
| 37 | ] as const satisfies readonly CursorModelFamilyOption[]; | |
| 38 | ||
| 39 | export type CursorModelFamily = (typeof CURSOR_MODEL_FAMILY_OPTIONS)[number]["slug"]; | |
| 40 | ||
| 8 | 41 | export const MODEL_OPTIONS_BY_PROVIDER = { |
| 9 | 42 | codex: [ |
| 10 | 43 | { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, |
| ... | ... | @@ -17,13 +50,218 @@ export const MODEL_OPTIONS_BY_PROVIDER = { |
| 17 | 50 | { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, |
| 18 | 51 | { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" }, |
| 19 | 52 | ], |
| 53 | cursor: [ | |
| 54 | { slug: "auto", name: "Auto" }, | |
| 55 | { slug: "composer-1.5", name: "Composer 1.5" }, | |
| 56 | { slug: "composer-1", name: "Composer 1" }, | |
| 57 | { slug: "gpt-5.3-codex-low", name: "GPT-5.3 Codex Low" }, | |
| 58 | { slug: "gpt-5.3-codex-low-fast", name: "GPT-5.3 Codex Low Fast" }, | |
| 59 | { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, | |
| 60 | { slug: "gpt-5.3-codex-fast", name: "GPT-5.3 Codex Fast" }, | |
| 61 | { slug: "gpt-5.3-codex-high", name: "GPT-5.3 Codex High" }, | |
| 62 | { slug: "gpt-5.3-codex-high-fast", name: "GPT-5.3 Codex High Fast" }, | |
| 63 | { slug: "gpt-5.3-codex-xhigh", name: "GPT-5.3 Codex Extra High" }, | |
| 64 | { slug: "gpt-5.3-codex-xhigh-fast", name: "GPT-5.3 Codex Extra High Fast" }, | |
| 65 | { slug: "gpt-5.3-codex-spark-preview", name: "GPT-5.3 Codex Spark" }, | |
| 66 | { slug: "gpt-5.2", name: "GPT-5.2" }, | |
| 67 | { slug: "gpt-5.2-codex-low", name: "GPT-5.2 Codex Low" }, | |
| 68 | { slug: "gpt-5.2-codex-low-fast", name: "GPT-5.2 Codex Low Fast" }, | |
| 69 | { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, | |
| 70 | { slug: "gpt-5.2-codex-fast", name: "GPT-5.2 Codex Fast" }, | |
| 71 | { slug: "gpt-5.2-codex-high", name: "GPT-5.2 Codex High" }, | |
| 72 | { slug: "gpt-5.2-codex-high-fast", name: "GPT-5.2 Codex High Fast" }, | |
| 73 | { slug: "gpt-5.2-codex-xhigh", name: "GPT-5.2 Codex Extra High" }, | |
| 74 | { slug: "gpt-5.2-codex-xhigh-fast", name: "GPT-5.2 Codex Extra High Fast" }, | |
| 75 | { slug: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" }, | |
| 76 | { slug: "gpt-5.1-codex-max-high", name: "GPT-5.1 Codex Max High" }, | |
| 77 | { slug: "gpt-5.2-high", name: "GPT-5.2 High" }, | |
| 78 | { slug: "gpt-5.1-high", name: "GPT-5.1 High" }, | |
| 79 | { slug: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" }, | |
| 80 | { slug: "opus-4.6-thinking", name: "Claude 4.6 Opus (Thinking)" }, | |
| 81 | { slug: "opus-4.6", name: "Claude 4.6 Opus" }, | |
| 82 | { slug: "opus-4.5", name: "Claude 4.5 Opus" }, | |
| 83 | { slug: "opus-4.5-thinking", name: "Claude 4.5 Opus (Thinking)" }, | |
| 84 | { slug: "sonnet-4.6", name: "Claude 4.6 Sonnet" }, | |
| 85 | { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, | |
| 86 | { slug: "gemini-3-pro", name: "Gemini 3 Pro" }, | |
| 87 | { slug: "sonnet-4.6-thinking", name: "Claude 4.6 Sonnet (Thinking)" }, | |
| 88 | { slug: "sonnet-4.5", name: "Claude 4.5 Sonnet" }, | |
| 89 | { slug: "sonnet-4.5-thinking", name: "Claude 4.5 Sonnet (Thinking)" }, | |
| 90 | { slug: "gemini-3-flash", name: "Gemini 3 Flash" }, | |
| 91 | { slug: "grok", name: "Grok" }, | |
| 92 | { slug: "kimi-k2.5", name: "Kimi K2.5" }, | |
| 93 | ], | |
| 20 | 94 | } as const satisfies Record<ProviderKind, readonly ModelOption[]>; |
| 21 | 95 | |
| 22 | 96 | export type ModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"]; |
| 97 | export type CursorModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)["cursor"][number]["slug"]; | |
| 98 | ||
| 99 | type CursorModelCapability = { | |
| 100 | readonly supportsReasoning: boolean; | |
| 101 | readonly supportsFast: boolean; | |
| 102 | readonly supportsThinking: boolean; | |
| 103 | readonly defaultReasoning: CursorReasoningOption; | |
| 104 | readonly defaultThinking: boolean; | |
| 105 | }; | |
| 106 | ||
| 107 | const CURSOR_MODEL_CAPABILITY_BY_FAMILY: Record<CursorModelFamily, CursorModelCapability> = { | |
| 108 | auto: { | |
| 109 | supportsReasoning: false, | |
| 110 | supportsFast: false, | |
| 111 | supportsThinking: false, | |
| 112 | defaultReasoning: "normal", | |
| 113 | defaultThinking: false, | |
| 114 | }, | |
| 115 | "composer-1.5": { | |
| 116 | supportsReasoning: false, | |
| 117 | supportsFast: false, | |
| 118 | supportsThinking: false, | |
| 119 | defaultReasoning: "normal", | |
| 120 | defaultThinking: false, | |
| 121 | }, | |
| 122 | "composer-1": { | |
| 123 | supportsReasoning: false, | |
| 124 | supportsFast: false, | |
| 125 | supportsThinking: false, | |
| 126 | defaultReasoning: "normal", | |
| 127 | defaultThinking: false, | |
| 128 | }, | |
| 129 | "gpt-5.3-codex": { | |
| 130 | supportsReasoning: true, | |
| 131 | supportsFast: true, | |
| 132 | supportsThinking: false, | |
| 133 | defaultReasoning: "normal", | |
| 134 | defaultThinking: false, | |
| 135 | }, | |
| 136 | "gpt-5.3-codex-spark-preview": { | |
| 137 | supportsReasoning: false, | |
| 138 | supportsFast: false, | |
| 139 | supportsThinking: false, | |
| 140 | defaultReasoning: "normal", | |
| 141 | defaultThinking: false, | |
| 142 | }, | |
| 143 | "gpt-5.2-codex": { | |
| 144 | supportsReasoning: true, | |
| 145 | supportsFast: true, | |
| 146 | supportsThinking: false, | |
| 147 | defaultReasoning: "normal", | |
| 148 | defaultThinking: false, | |
| 149 | }, | |
| 150 | "gpt-5.2": { | |
| 151 | supportsReasoning: false, | |
| 152 | supportsFast: false, | |
| 153 | supportsThinking: false, | |
| 154 | defaultReasoning: "normal", | |
| 155 | defaultThinking: false, | |
| 156 | }, | |
| 157 | "gpt-5.2-high": { | |
| 158 | supportsReasoning: false, | |
| 159 | supportsFast: false, | |
| 160 | supportsThinking: false, | |
| 161 | defaultReasoning: "normal", | |
| 162 | defaultThinking: false, | |
| 163 | }, | |
| 164 | "gpt-5.1-codex-max": { | |
| 165 | supportsReasoning: false, | |
| 166 | supportsFast: false, | |
| 167 | supportsThinking: false, | |
| 168 | defaultReasoning: "normal", | |
| 169 | defaultThinking: false, | |
| 170 | }, | |
| 171 | "gpt-5.1-codex-mini": { | |
| 172 | supportsReasoning: false, | |
| 173 | supportsFast: false, | |
| 174 | supportsThinking: false, | |
| 175 | defaultReasoning: "normal", | |
| 176 | defaultThinking: false, | |
| 177 | }, | |
| 178 | "gpt-5.1-high": { | |
| 179 | supportsReasoning: false, | |
| 180 | supportsFast: false, | |
| 181 | supportsThinking: false, | |
| 182 | defaultReasoning: "normal", | |
| 183 | defaultThinking: false, | |
| 184 | }, | |
| 185 | "opus-4.6": { | |
| 186 | supportsReasoning: false, | |
| 187 | supportsFast: false, | |
| 188 | supportsThinking: true, | |
| 189 | defaultReasoning: "normal", | |
| 190 | defaultThinking: true, | |
| 191 | }, | |
| 192 | "opus-4.5": { | |
| 193 | supportsReasoning: false, | |
| 194 | supportsFast: false, | |
| 195 | supportsThinking: true, | |
| 196 | defaultReasoning: "normal", | |
| 197 | defaultThinking: true, | |
| 198 | }, | |
| 199 | "sonnet-4.6": { | |
| 200 | supportsReasoning: false, | |
| 201 | supportsFast: false, | |
| 202 | supportsThinking: true, | |
| 203 | defaultReasoning: "normal", | |
| 204 | defaultThinking: true, | |
| 205 | }, | |
| 206 | "sonnet-4.5": { | |
| 207 | supportsReasoning: false, | |
| 208 | supportsFast: false, | |
| 209 | supportsThinking: true, | |
| 210 | defaultReasoning: "normal", | |
| 211 | defaultThinking: true, | |
| 212 | }, | |
| 213 | "gemini-3.1-pro": { | |
| 214 | supportsReasoning: false, | |
| 215 | supportsFast: false, | |
| 216 | supportsThinking: false, | |
| 217 | defaultReasoning: "normal", | |
| 218 | defaultThinking: false, | |
| 219 | }, | |
| 220 | "gemini-3-pro": { | |
| 221 | supportsReasoning: false, | |
| 222 | supportsFast: false, | |
| 223 | supportsThinking: false, | |
| 224 | defaultReasoning: "normal", | |
| 225 | defaultThinking: false, | |
| 226 | }, | |
| 227 | "gemini-3-flash": { | |
| 228 | supportsReasoning: false, | |
| 229 | supportsFast: false, | |
| 230 | supportsThinking: false, | |
| 231 | defaultReasoning: "normal", | |
| 232 | defaultThinking: false, | |
| 233 | }, | |
| 234 | grok: { | |
| 235 | supportsReasoning: false, | |
| 236 | supportsFast: false, | |
| 237 | supportsThinking: false, | |
| 238 | defaultReasoning: "normal", | |
| 239 | defaultThinking: false, | |
| 240 | }, | |
| 241 | "kimi-k2.5": { | |
| 242 | supportsReasoning: false, | |
| 243 | supportsFast: false, | |
| 244 | supportsThinking: false, | |
| 245 | defaultReasoning: "normal", | |
| 246 | defaultThinking: false, | |
| 247 | }, | |
| 248 | }; | |
| 249 | ||
| 250 | const CURSOR_MODEL_FAMILY_SET = new Set<CursorModelFamily>( | |
| 251 | CURSOR_MODEL_FAMILY_OPTIONS.map((option) => option.slug), | |
| 252 | ); | |
| 253 | ||
| 254 | export interface CursorModelSelection { | |
| 255 | readonly family: CursorModelFamily; | |
| 256 | readonly reasoning: CursorReasoningOption; | |
| 257 | readonly fast: boolean; | |
| 258 | readonly thinking: boolean; | |
| 259 | } | |
| 23 | 260 | |
| 24 | 261 | export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, ModelSlug> = { |
| 25 | 262 | codex: "gpt-5.3-codex", |
| 26 | 263 | claudeCode: "claude-sonnet-4-6", |
| 264 | cursor: "opus-4.6-thinking", | |
| 27 | 265 | }; |
| 28 | 266 | |
| 29 | 267 | // Backward compatibility for existing Codex-only call sites. |
| ... | ... | @@ -38,30 +276,180 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, |
| 38 | 276 | "gpt-5.3-spark": "gpt-5.3-codex-spark", |
| 39 | 277 | }, |
| 40 | 278 | claudeCode: { |
| 41 | sonnet: "claude-sonnet-4-6", | |
| 42 | "sonnet-4.6": "claude-sonnet-4-6", | |
| 43 | "claude-sonnet-4.6": "claude-sonnet-4-6", | |
| 44 | "claude-sonnet-4-6-20251117": "claude-sonnet-4-6", | |
| 45 | 279 | opus: "claude-opus-4-6", |
| 46 | 280 | "opus-4.6": "claude-opus-4-6", |
| 47 | 281 | "claude-opus-4.6": "claude-opus-4-6", |
| 48 | 282 | "claude-opus-4-6-20251117": "claude-opus-4-6", |
| 283 | sonnet: "claude-sonnet-4-6", | |
| 284 | "sonnet-4.6": "claude-sonnet-4-6", | |
| 285 | "claude-sonnet-4.6": "claude-sonnet-4-6", | |
| 286 | "claude-sonnet-4-6-20251117": "claude-sonnet-4-6", | |
| 49 | 287 | haiku: "claude-haiku-4-5", |
| 50 | 288 | "haiku-4.5": "claude-haiku-4-5", |
| 51 | 289 | "claude-haiku-4.5": "claude-haiku-4-5", |
| 52 | 290 | "claude-haiku-4-5-20251001": "claude-haiku-4-5", |
| 53 | 291 | }, |
| 292 | cursor: { | |
| 293 | composer: "composer-1.5", | |
| 294 | "composer-1.5": "composer-1.5", | |
| 295 | "composer-1": "composer-1", | |
| 296 | "gpt-5.3-codex": "gpt-5.3-codex", | |
| 297 | "gpt-5.3-codex-spark": "gpt-5.3-codex-spark-preview", | |
| 298 | "gpt-5.2-codex": "gpt-5.2-codex", | |
| 299 | "gpt-5.1-codex-max-high": "gpt-5.1-codex-max-high", | |
| 300 | "gpt-5.1-codex-mini": "gpt-5.1-codex-mini", | |
| 301 | "gemini-3.1": "gemini-3.1-pro", | |
| 302 | "gemini-3.1-pro": "gemini-3.1-pro", | |
| 303 | "gemini-3-pro": "gemini-3-pro", | |
| 304 | "claude-4.6-sonnet-thinking": "sonnet-4.6-thinking", | |
| 305 | "claude-4.5-sonnet-thinking": "sonnet-4.5-thinking", | |
| 306 | "claude-4.6-opus-thinking": "opus-4.6-thinking", | |
| 307 | "claude-4.5-opus-thinking": "opus-4.5-thinking", | |
| 308 | "sonnet-4.6-thinking": "sonnet-4.6-thinking", | |
| 309 | "sonnet-4.5-thinking": "sonnet-4.5-thinking", | |
| 310 | "opus-4.6-thinking": "opus-4.6-thinking", | |
| 311 | "opus-4.5-thinking": "opus-4.5-thinking", | |
| 312 | }, | |
| 54 | 313 | }; |
| 55 | 314 | |
| 56 | 315 | const MODEL_SLUG_SET_BY_PROVIDER: Record<ProviderKind, ReadonlySet<ModelSlug>> = { |
| 57 | codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), | |
| 58 | 316 | claudeCode: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeCode.map((option) => option.slug)), |
| 317 | codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), | |
| 318 | cursor: new Set(MODEL_OPTIONS_BY_PROVIDER.cursor.map((option) => option.slug)), | |
| 59 | 319 | }; |
| 60 | 320 | |
| 61 | 321 | export function getModelOptions(provider: ProviderKind = "codex") { |
| 62 | 322 | return MODEL_OPTIONS_BY_PROVIDER[provider]; |
| 63 | 323 | } |
| 64 | 324 | |
| 325 | export function getCursorModelFamilyOptions() { | |
| 326 | return CURSOR_MODEL_FAMILY_OPTIONS; | |
| 327 | } | |
| 328 | ||
| 329 | export function getCursorModelCapabilities(family: CursorModelFamily) { | |
| 330 | return CURSOR_MODEL_CAPABILITY_BY_FAMILY[family]; | |
| 331 | } | |
| 332 | ||
| 333 | function fallbackCursorModelFamily(): CursorModelFamily { | |
| 334 | const fallback = parseCursorModelSelection(DEFAULT_MODEL_BY_PROVIDER.cursor); | |
| 335 | return fallback.family; | |
| 336 | } | |
| 337 | ||
| 338 | function resolveCursorModelFamily(model: string | null | undefined): CursorModelFamily { | |
| 339 | const normalized = normalizeModelSlug(model, "cursor"); | |
| 340 | if (!normalized) { | |
| 341 | return fallbackCursorModelFamily(); | |
| 342 | } | |
| 343 | ||
| 344 | if ( | |
| 345 | normalized === "gpt-5.3-codex" || | |
| 346 | normalized === "gpt-5.3-codex-fast" || | |
| 347 | normalized === "gpt-5.3-codex-low" || | |
| 348 | normalized === "gpt-5.3-codex-low-fast" || | |
| 349 | normalized === "gpt-5.3-codex-high" || | |
| 350 | normalized === "gpt-5.3-codex-high-fast" || | |
| 351 | normalized === "gpt-5.3-codex-xhigh" || | |
| 352 | normalized === "gpt-5.3-codex-xhigh-fast" | |
| 353 | ) { | |
| 354 | return "gpt-5.3-codex"; | |
| 355 | } | |
| 356 | ||
| 357 | if ( | |
| 358 | normalized === "gpt-5.2-codex" || | |
| 359 | normalized === "gpt-5.2-codex-fast" || | |
| 360 | normalized === "gpt-5.2-codex-low" || | |
| 361 | normalized === "gpt-5.2-codex-low-fast" || | |
| 362 | normalized === "gpt-5.2-codex-high" || | |
| 363 | normalized === "gpt-5.2-codex-high-fast" || | |
| 364 | normalized === "gpt-5.2-codex-xhigh" || | |
| 365 | normalized === "gpt-5.2-codex-xhigh-fast" | |
| 366 | ) { | |
| 367 | return "gpt-5.2-codex"; | |
| 368 | } | |
| 369 | ||
| 370 | if (normalized === "sonnet-4.6-thinking") { | |
| 371 | return "sonnet-4.6"; | |
| 372 | } | |
| 373 | if (normalized === "sonnet-4.5-thinking") { | |
| 374 | return "sonnet-4.5"; | |
| 375 | } | |
| 376 | if (normalized === "opus-4.6-thinking") { | |
| 377 | return "opus-4.6"; | |
| 378 | } | |
| 379 | if (normalized === "opus-4.5-thinking") { | |
| 380 | return "opus-4.5"; | |
| 381 | } | |
| 382 | ||
| 383 | return CURSOR_MODEL_FAMILY_SET.has(normalized as CursorModelFamily) | |
| 384 | ? (normalized as CursorModelFamily) | |
| 385 | : fallbackCursorModelFamily(); | |
| 386 | } | |
| 387 | ||
| 388 | function resolveCursorReasoning(model: CursorModelSlug): CursorReasoningOption { | |
| 389 | if (model.includes("-xhigh")) return "xhigh"; | |
| 390 | if (model.includes("-high")) return "high"; | |
| 391 | if (model.includes("-low")) return "low"; | |
| 392 | return "normal"; | |
| 393 | } | |
| 394 | ||
| 395 | export function parseCursorModelSelection(model: string | null | undefined): CursorModelSelection { | |
| 396 | const family = resolveCursorModelFamily(model); | |
| 397 | const capability = CURSOR_MODEL_CAPABILITY_BY_FAMILY[family]; | |
| 398 | const normalized = resolveModelSlugForProvider("cursor", model) as CursorModelSlug; | |
| 399 | ||
| 400 | if (capability.supportsReasoning) { | |
| 401 | return { | |
| 402 | family, | |
| 403 | reasoning: resolveCursorReasoning(normalized), | |
| 404 | fast: normalized.endsWith("-fast"), | |
| 405 | thinking: false, | |
| 406 | }; | |
| 407 | } | |
| 408 | ||
| 409 | if (capability.supportsThinking) { | |
| 410 | return { | |
| 411 | family, | |
| 412 | reasoning: capability.defaultReasoning, | |
| 413 | fast: false, | |
| 414 | thinking: normalized.endsWith("-thinking"), | |
| 415 | }; | |
| 416 | } | |
| 417 | ||
| 418 | return { | |
| 419 | family, | |
| 420 | reasoning: capability.defaultReasoning, | |
| 421 | fast: false, | |
| 422 | thinking: capability.defaultThinking, | |
| 423 | }; | |
| 424 | } | |
| 425 | ||
| 426 | export function resolveCursorModelFromSelection(input: { | |
| 427 | readonly family: CursorModelFamily; | |
| 428 | readonly reasoning?: CursorReasoningOption | null; | |
| 429 | readonly fast?: boolean; | |
| 430 | readonly thinking?: boolean; | |
| 431 | }): CursorModelSlug { | |
| 432 | const family = resolveCursorModelFamily(input.family); | |
| 433 | const capability = CURSOR_MODEL_CAPABILITY_BY_FAMILY[family]; | |
| 434 | ||
| 435 | if (capability.supportsReasoning) { | |
| 436 | const reasoning = CURSOR_REASONING_OPTIONS.includes(input.reasoning ?? "normal") | |
| 437 | ? (input.reasoning ?? "normal") | |
| 438 | : capability.defaultReasoning; | |
| 439 | const reasoningSuffix = reasoning === "normal" ? "" : `-${reasoning}`; | |
| 440 | const fastSuffix = input.fast ? "-fast" : ""; | |
| 441 | const candidate = `${family}${reasoningSuffix}${fastSuffix}`; | |
| 442 | return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug; | |
| 443 | } | |
| 444 | ||
| 445 | if (capability.supportsThinking) { | |
| 446 | const candidate = input.thinking ? `${family}-thinking` : family; | |
| 447 | return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug; | |
| 448 | } | |
| 449 | ||
| 450 | return resolveModelSlugForProvider("cursor", family) as CursorModelSlug; | |
| 451 | } | |
| 452 | ||
| 65 | 453 | export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug { |
| 66 | 454 | return DEFAULT_MODEL_BY_PROVIDER[provider]; |
| 67 | 455 | } |
packages/contracts/src/orchestration.test.ts+19| ... | ... | @@ -138,6 +138,25 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", ( |
| 138 | 138 | }), |
| 139 | 139 | ); |
| 140 | 140 | |
| 141 | it.effect("accepts cursor provider in thread.turn.start", () => | |
| 142 | Effect.gen(function* () { | |
| 143 | const parsed = yield* decodeThreadTurnStartCommand({ | |
| 144 | type: "thread.turn.start", | |
| 145 | commandId: "cmd-turn-cursor", | |
| 146 | threadId: "thread-1", | |
| 147 | message: { | |
| 148 | messageId: "msg-3", | |
| 149 | role: "user", | |
| 150 | text: "hello", | |
| 151 | attachments: [], | |
| 152 | }, | |
| 153 | provider: "cursor", | |
| 154 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 155 | }); | |
| 156 | assert.strictEqual(parsed.provider, "cursor"); | |
| 157 | }), | |
| 158 | ); | |
| 159 | ||
| 141 | 160 | it.effect("decodes thread.turn-start-requested defaults for provider and runtime mode", () => |
| 142 | 161 | Effect.gen(function* () { |
| 143 | 162 | const parsed = yield* decodeThreadTurnStartRequestedPayload({ |
packages/contracts/src/orchestration.ts+1-1| ... | ... | @@ -29,7 +29,7 @@ export const ORCHESTRATION_WS_CHANNELS = { |
| 29 | 29 | domainEvent: "orchestration.domainEvent", |
| 30 | 30 | } as const; |
| 31 | 31 | |
| 32 | export const ProviderKind = Schema.Literals(["codex", "claudeCode"]); | |
| 32 | export const ProviderKind = Schema.Literals(["codex", "claudeCode", "cursor"]); | |
| 33 | 33 | export type ProviderKind = typeof ProviderKind.Type; |
| 34 | 34 | export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; |
| 35 | 35 | export const ProviderApprovalPolicy = Schema.Literals([ |
packages/contracts/src/provider.test.ts+14| ... | ... | @@ -46,4 +46,18 @@ describe("ProviderSessionStartInput", () => { |
| 46 | 46 | expect(parsed.approvalPolicy).toBe("never"); |
| 47 | 47 | expect(parsed.sandboxMode).toBe("danger-full-access"); |
| 48 | 48 | }); |
| 49 | ||
| 50 | it("accepts cursor provider payloads", () => { | |
| 51 | const parsed = decodeProviderSessionStartInput({ | |
| 52 | provider: "cursor", | |
| 53 | cwd: "/tmp/workspace", | |
| 54 | model: "composer-1.5", | |
| 55 | approvalPolicy: "on-request", | |
| 56 | sandboxMode: "workspace-write", | |
| 57 | }); | |
| 58 | expect(parsed.provider).toBe("cursor"); | |
| 59 | expect(parsed.model).toBe("composer-1.5"); | |
| 60 | expect(parsed.approvalPolicy).toBe("on-request"); | |
| 61 | expect(parsed.sandboxMode).toBe("workspace-write"); | |
| 62 | }); | |
| 49 | 63 | }); |