authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-28 10:27:26-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log86e204439e5d065fe1063a2a28df80863528867a
tree8854254483ca8315577e2852673838e1916024a3
parent04a931fc7992152b388faef5cda28e64a0887303

Add Cursor provider support across backend, contracts, and UI

- 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 tests

16 files changed, 1145 insertions(+), 85 deletions(-)

apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+41-1
...@@ -91,7 +91,9 @@ describe("ProviderCommandReactor", () => {...@@ -91,7 +91,9 @@ describe("ProviderCommandReactor", () => {
91 typeof input === "object" &&91 typeof input === "object" &&
92 input !== null &&92 input !== null &&
93 "provider" in input &&93 "provider" in input &&
94 (input.provider === "codex" || input.provider === "claudeCode")94 (input.provider === "codex" ||
95 input.provider === "claudeCode" ||
96 input.provider === "cursor")
95 ? input.provider97 ? input.provider
96 : "codex";98 : "codex";
97 const resumeCursor =99 const resumeCursor =
...@@ -285,6 +287,44 @@ describe("ProviderCommandReactor", () => {...@@ -285,6 +287,44 @@ describe("ProviderCommandReactor", () => {
285 expect(thread?.session?.providerSessionId).toBe("sess-1");287 expect(thread?.session?.providerSessionId).toBe("sess-1");
286 });288 });
287289
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 it("reuses the same provider session when runtime mode is unchanged", async () => {328 it("reuses the same provider session when runtime mode is unchanged", async () => {
289 const harness = await createHarness();329 const harness = await createHarness();
290 const now = new Date().toISOString();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,7 +184,9 @@ const make = Effect.gen(function* () {
184 const desiredSandboxMode =184 const desiredSandboxMode =
185 options?.sandboxMode ?? thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE;185 options?.sandboxMode ?? thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE;
186 const currentProvider: ProviderKind | undefined =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 ? thread.session.providerName190 ? thread.session.providerName
189 : undefined;191 : undefined;
190 const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider;192 const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider;
apps/server/src/persistence/Layers/ProviderSessions.ts+1-1
...@@ -13,7 +13,7 @@ import {...@@ -13,7 +13,7 @@ import {
13 type ProviderSessionRepositoryShape,13 type ProviderSessionRepositoryShape,
14} from "../Services/ProviderSessions.ts";14} from "../Services/ProviderSessions.ts";
1515
16const ProviderKind = Schema.Literals(["codex", "claudeCode"]);16const ProviderKind = Schema.Literals(["codex", "claudeCode", "cursor"]);
1717
18const ProviderSessionRowSchema = Schema.Struct({18const ProviderSessionRowSchema = Schema.Struct({
19 sessionId: ProviderSessionId,19 sessionId: ProviderSessionId,
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+18
...@@ -225,4 +225,22 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL...@@ -225,4 +225,22 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
225225
226 fs.rmSync(tempDir, { recursive: true, force: true });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,7 +26,7 @@ function decodeProviderKind(
26 providerName: string,26 providerName: string,
27 operation: string,27 operation: string,
28): Effect.Effect<ProviderKind, ProviderSessionDirectoryPersistenceError> {28): Effect.Effect<ProviderKind, ProviderSessionDirectoryPersistenceError> {
29 if (providerName === "codex" || providerName === "claudeCode") {29 if (providerName === "codex" || providerName === "claudeCode" || providerName === "cursor") {
30 return Effect.succeed(providerName);30 return Effect.succeed(providerName);
31 }31 }
32 return Effect.fail(32 return Effect.fail(
apps/web/src/components/ChatView.tsx+334-71
...@@ -1,17 +1,23 @@...@@ -1,17 +1,23 @@
1import {1import {
2 type ApprovalRequestId,2 type ApprovalRequestId,
3 CURSOR_REASONING_OPTIONS,
3 DEFAULT_MODEL,4 DEFAULT_MODEL,
4 DEFAULT_REASONING,5 DEFAULT_REASONING,
5 EDITORS,6 EDITORS,
6 type EditorId,7 type EditorId,
7 type KeybindingCommand,8 type KeybindingCommand,
9 type CursorReasoningOption,
8 type MessageId,10 type MessageId,
9 getDefaultModel,11 getDefaultModel,
12 getCursorModelCapabilities,
13 getCursorModelFamilyOptions,
10 getModelOptions,14 getModelOptions,
11 getReasoningOptions,15 getReasoningOptions,
12 type ProjectId,16 type ProjectId,
13 type ProjectEntry,17 type ProjectEntry,
14 type ProjectScript,18 type ProjectScript,
19 type ModelSlug,
20 parseCursorModelSelection,
15 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,21 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
16 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,22 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
17 type ReasoningEffort,23 type ReasoningEffort,
...@@ -22,6 +28,7 @@ import {...@@ -22,6 +28,7 @@ import {
22 type ThreadId,28 type ThreadId,
23 type TurnId,29 type TurnId,
24 normalizeModelSlug,30 normalizeModelSlug,
31 resolveCursorModelFromSelection,
25 resolveModelSlugForProvider,32 resolveModelSlugForProvider,
26 OrchestrationThreadActivity,33 OrchestrationThreadActivity,
27} from "@t3tools/contracts";34} from "@t3tools/contracts";
...@@ -109,8 +116,6 @@ import {...@@ -109,8 +116,6 @@ import {
109import { Button } from "./ui/button";116import { Button } from "./ui/button";
110import {117import {
111 Select,118 Select,
112 SelectGroup,
113 SelectGroupLabel,
114 SelectItem,119 SelectItem,
115 SelectPopup,120 SelectPopup,
116 SelectTrigger,121 SelectTrigger,
...@@ -118,8 +123,21 @@ import {...@@ -118,8 +123,21 @@ import {
118} from "./ui/select";123} from "./ui/select";
119import { Separator } from "./ui/separator";124import { Separator } from "./ui/separator";
120import { Group, GroupSeparator } from "./ui/group";125import { Group, GroupSeparator } from "./ui/group";
121import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";126import {
122import { ClaudeAI, CursorIcon, Icon, OpenAI } from "./Icons";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";
140import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "./Icons";
123import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";141import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
124import { Badge } from "./ui/badge";142import { Badge } from "./ui/badge";
125import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";143import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
...@@ -525,6 +543,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -525,6 +543,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
525 const [selectedProviderByThread, setSelectedProviderByThread] = useState<543 const [selectedProviderByThread, setSelectedProviderByThread] = useState<
526 Partial<Record<ThreadId, ProviderKind>>544 Partial<Record<ThreadId, ProviderKind>>
527 >({});545 >({});
546 const [selectedModelByThread, setSelectedModelByThread] = useState<
547 Partial<Record<ThreadId, ModelSlug>>
548 >({});
528 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);549 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
529 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);550 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
530 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});551 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
...@@ -655,16 +676,37 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -655,16 +676,37 @@ export default function ChatView({ threadId }: ChatViewProps) {
655 "codex";676 "codex";
656 const baseThreadModel = resolveModelSlugForProvider(677 const baseThreadModel = resolveModelSlugForProvider(
657 selectedProvider,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 const selectedModel = resolveModelSlugForProvider(selectedProvider, composerDraft.model ?? baseThreadModel);685 const selectedModel = resolveModelSlugForProvider(selectedProvider, composerDraft.model ?? baseThreadModel);
661 const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING;686 const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING;
662 const reasoningOptions = getReasoningOptions(selectedProvider);687 const reasoningOptions = getReasoningOptions(selectedProvider);
663 const supportsReasoningEffort = reasoningOptions.length > 0;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 const searchableModelOptions = useMemo(706 const searchableModelOptions = useMemo(
665 () =>707 () =>
666 PROVIDER_OPTIONS.filter((option) => option.available).flatMap((option) =>708 PROVIDER_OPTIONS.filter((option) => option.available).flatMap((option) =>
667 getModelOptions(option.value).map(({ slug, name }) => ({709 getProviderModelPickerOptions(option.value).map(({ slug, name }) => ({
668 provider: option.value,710 provider: option.value,
669 providerLabel: option.label,711 providerLabel: option.label,
670 slug,712 slug,
...@@ -683,6 +725,22 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -683,6 +725,22 @@ export default function ChatView({ threadId }: ChatViewProps) {
683 return { ...existing, [activeThread.id]: sessionProvider };725 return { ...existing, [activeThread.id]: sessionProvider };
684 });726 });
685 }, [activeThread?.id, sessionProvider]);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 const phase = derivePhase(activeThread?.session ?? null);744 const phase = derivePhase(activeThread?.session ?? null);
687 const isSendBusy = sendPhase !== "idle";745 const isSendBusy = sendPhase !== "idle";
688 const isPreparingWorktree = sendPhase === "preparing-worktree";746 const isPreparingWorktree = sendPhase === "preparing-worktree";
...@@ -2261,22 +2319,72 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2261,22 +2319,72 @@ export default function ChatView({ threadId }: ChatViewProps) {
2261 (provider: ProviderKind, model: ModelSlug) => {2319 (provider: ProviderKind, model: ModelSlug) => {
2262 const api = readNativeApi();2320 const api = readNativeApi();
2263 if (!activeThread) return;2321 if (!activeThread) return;
2322 const resolvedModel =
2323 provider === "cursor"
2324 ? resolveCursorModelFromSelection(parseCursorModelSelection(model))
2325 : resolveModelSlugForProvider(provider, model);
2264 setSelectedProviderByThread((existing) => ({2326 setSelectedProviderByThread((existing) => ({
2265 ...existing,2327 ...existing,
2266 [activeThread.id]: provider,2328 [activeThread.id]: provider,
2267 }));2329 }));
2330 setSelectedModelByThread((existing) => ({
2331 ...existing,
2332 [activeThread.id]: resolvedModel,
2333 }));
2268 if (api) {2334 if (api) {
2269 void api.orchestration.dispatchCommand({2335 void api.orchestration.dispatchCommand({
2270 type: "thread.meta.update",2336 type: "thread.meta.update",
2271 commandId: newCommandId(),2337 commandId: newCommandId(),
2272 threadId: activeThread.id,2338 threadId: activeThread.id,
2273 model: resolveModelSlugForProvider(provider, model),2339 model: resolvedModel,
2274 });2340 });
2275 }2341 }
2276 scheduleComposerFocus();2342 scheduleComposerFocus();
2277 },2343 },
2278 [activeThread, scheduleComposerFocus],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 const onEffortSelect = useCallback(2388 const onEffortSelect = useCallback(
2281 (effort: ReasoningEffort) => {2389 (effort: ReasoningEffort) => {
2282 setComposerDraftEffort(threadId, effort);2390 setComposerDraftEffort(threadId, effort);
...@@ -2716,26 +2824,43 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2716,26 +2824,43 @@ export default function ChatView({ threadId }: ChatViewProps) {
2716 {/* Provider/model picker */}2824 {/* Provider/model picker */}
2717 <ProviderModelPicker2825 <ProviderModelPicker
2718 provider={selectedProvider}2826 provider={selectedProvider}
2719 model={selectedModel}2827 model={selectedModelForPicker}
2720 onProviderModelChange={onProviderModelSelect}2828 onProviderModelChange={onProviderModelSelect}
2721 />2829 />
27222830
2723 {/* Divider */}2831 {selectedProvider === "cursor" ? (
2724 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />2832 <>
2833 {hasSelectedCursorTraits && (
2834 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
2835 )}
27252836
2726 {/* Reasoning effort */}2837 {selectedCursorModel && selectedCursorModelCapabilities && hasSelectedCursorTraits && (
2727 {supportsReasoningEffort ? (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 <ReasoningEffortPicker2853 <ReasoningEffortPicker
2730 effort={selectedEffort}2854 effort={selectedEffort}
2731 options={reasoningOptions}2855 options={reasoningOptions}
2732 onEffortChange={onEffortSelect}2856 onEffortChange={onEffortSelect}
2733 />2857 />
2734 {/* Divider */}
2735 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
2736 </>2858 </>
2737 ) : null}2859 ) : null}
27382860
2861 {/* Divider */}
2862 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
2863
2739 {/* Runtime mode toggle */}2864 {/* Runtime mode toggle */}
2740 <Button2865 <Button
2741 variant="ghost"2866 variant="ghost"
...@@ -3868,87 +3993,141 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -3868,87 +3993,141 @@ const MessagesTimeline = memo(function MessagesTimeline({
3868 );3993 );
3869});3994});
38703995
3871const PROVIDER_MODEL_GROUPS: ReadonlyArray<{3996const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => option.available);
3872 readonly provider: ProviderKind;3997const COMING_SOON_PROVIDER_OPTIONS = [
3873 readonly label: string;3998 { id: "opencode", label: "OpenCode", icon: OpenCodeIcon },
3874 readonly options: ReadonlyArray<{ readonly slug: ModelSlug; readonly name: string }>;3999 { id: "gemini", label: "Gemini", icon: Gemini },
3875}> = PROVIDER_OPTIONS.filter((option) => option.available).map((option) => ({4000] as const;
3876 provider: option.value,4001
3877 label: option.label,4002function getProviderModelPickerOptions(provider: ProviderKind) {
3878 options: getModelOptions(option.value),4003 if (provider === "cursor") {
3879}));4004 return getCursorModelFamilyOptions();
4005 }
4006 return getModelOptions(provider);
4007}
38804008
3881const PROVIDER_ICON_BY_PROVIDER: Record<ProviderKind, Icon> = {4009const PROVIDER_ICON_BY_PROVIDER: Record<ProviderKind, Icon> = {
3882 codex: OpenAI,4010 codex: OpenAI,
3883 claudeCode: ClaudeAI,4011 claudeCode: ClaudeAI,
4012 cursor: CursorIcon,
3884};4013};
38854014
4015function 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
3886const ProviderModelPicker = memo(function ProviderModelPicker(props: {4052const ProviderModelPicker = memo(function ProviderModelPicker(props: {
3887 provider: ProviderKind;4053 provider: ProviderKind;
3888 model: ModelSlug;4054 model: ModelSlug;
3889 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;4055 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
3890}) {4056}) {
3891 const items = PROVIDER_MODEL_GROUPS.flatMap(({ provider, label, options }) =>4057 const [isMenuOpen, setIsMenuOpen] = useState(false);
3892 options.map(({ slug, name }) => ({4058 const selectedProviderOptions = getProviderModelPickerOptions(props.provider);
3893 label: `${label} · ${name}`,
3894 value: `${provider}:${slug}`,
3895 })),
3896 );
3897 const selectedModelLabel =4059 const selectedModelLabel =
3898 PROVIDER_MODEL_GROUPS.find((group) => group.provider === props.provider)?.options.find(4060 selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model;
3899 (option) => option.slug === props.model,
3900 )?.name ?? props.model;
3901 const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider];4061 const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider];
39024062
3903 return (4063 return (
3904 <Select4064 <Menu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
3905 items={items}4065 <MenuTrigger
3906 value={`${props.provider}:${props.model}`}4066 render={<Button size="sm" variant="ghost" className="shrink-0 whitespace-nowrap px-2 sm:px-3" />}
3907 onValueChange={(value) =>4067 >
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">
3928 <span className="flex min-w-0 items-center gap-2">4068 <span className="flex min-w-0 items-center gap-2">
3929 <ProviderIcon4069 <ProviderIcon
3930 aria-hidden="true"4070 aria-hidden="true"
3931 className={cn(4071 className={cn(
3932 "size-4 shrink-0",4072 "size-4 shrink-0",
3933 props.provider === "claudeCode" ? "" : "text-foreground/85",4073 props.provider === "claudeCode" ? "" : "text-foreground/80",
3934 )}4074 )}
3935 />4075 />
3936 <span className="truncate">{selectedModelLabel}</span>4076 <span className="truncate">{selectedModelLabel}</span>
4077 <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" />
3937 </span>4078 </span>
3938 </SelectTrigger>4079 </MenuTrigger>
3939 <SelectPopup alignItemWithTrigger={false}>4080 <MenuPopup align="start">
3940 {PROVIDER_MODEL_GROUPS.map(({ provider, label, options }) => (4081 {AVAILABLE_PROVIDER_OPTIONS.map((option) => {
3941 <SelectGroup key={provider}>4082 const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value];
3942 <SelectGroupLabel>{label}</SelectGroupLabel>4083 return (
3943 {options.map(({ slug, name }) => (4084 <MenuSub key={option.value}>
3944 <SelectItem key={`${provider}:${slug}`} value={`${provider}:${slug}`}>4085 <MenuSubTrigger>
3945 {name}4086 <OptionIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/85" />
3946 </SelectItem>4087 {option.label}
3947 ))}4088 </MenuSubTrigger>
3948 </SelectGroup>4089 <MenuSubPopup className="[--available-height:min(24rem,70vh)]">
3949 ))}4090 <MenuGroup>
3950 </SelectPopup>4091 <MenuRadioGroup
3951 </Select>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});
39544133
...@@ -3977,6 +4156,90 @@ const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: {...@@ -3977,6 +4156,90 @@ const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: {
3977 );4156 );
3978});4157});
39794158
4159const 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
3980const OpenInPicker = memo(function OpenInPicker({4243const OpenInPicker = memo(function OpenInPicker({
3981 keybindings,4244 keybindings,
3982 availableEditors,4245 availableEditors,
apps/web/src/components/Icons.tsx+182
...@@ -34,3 +34,185 @@ export const ClaudeAI: Icon = (props) => (...@@ -34,3 +34,185 @@ export const ClaudeAI: Icon = (props) => (
34 />34 />
35 </svg>35 </svg>
36);36);
37
38export 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
206export 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,4 +235,13 @@ describe("PROVIDER_OPTIONS", () => {
235 available: true,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,6 +15,7 @@ export const PROVIDER_OPTIONS: Array<{
15}> = [15}> = [
16 { value: "codex", label: "Codex", available: true },16 { value: "codex", label: "Codex", available: true },
17 { value: "claudeCode", label: "Claude Code", available: true },17 { value: "claudeCode", label: "Claude Code", available: true },
18 { value: "cursor", label: "Cursor Agent", available: true },
18];19];
1920
20export interface WorkLogEntry {21export interface WorkLogEntry {
apps/web/src/store.test.ts+26
...@@ -161,4 +161,30 @@ describe("store read model sync", () => {...@@ -161,4 +161,30 @@ describe("store read model sync", () => {
161161
162 expect(next.threads[0]?.model).toBe("claude-sonnet-4-6");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,6 +3,7 @@ import {
3 DEFAULT_MODEL,3 DEFAULT_MODEL,
4 getModelOptions,4 getModelOptions,
5 normalizeModelSlug,5 normalizeModelSlug,
6 type ProviderKind,
6 ProviderSessionId,7 ProviderSessionId,
7 ThreadId,8 ThreadId,
8 type OrchestrationReadModel,9 type OrchestrationReadModel,
...@@ -156,20 +157,37 @@ function toLegacySessionStatus(...@@ -156,20 +157,37 @@ function toLegacySessionStatus(
156 }157 }
157}158}
158159
159function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" {160function toLegacyProvider(providerName: string | null): ProviderKind {
160 return providerName === "claudeCode" ? "claudeCode" : "codex";161 if (providerName === "codex" || providerName === "claudeCode" || providerName === "cursor") {
162 return providerName;
163 }
164 return "codex";
161}165}
162166
163const CODEX_MODEL_SLUGS = new Set(getModelOptions("codex").map((option) => option.slug));167const CODEX_MODEL_SLUGS = new Set(getModelOptions("codex").map((option) => option.slug));
164const CLAUDE_MODEL_SLUGS = new Set(getModelOptions("claudeCode").map((option) => option.slug));168const CLAUDE_MODEL_SLUGS = new Set(getModelOptions("claudeCode").map((option) => option.slug));
169const CURSOR_MODEL_SLUGS = new Set(getModelOptions("cursor").map((option) => option.slug));
170const 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);
165175
166function inferProviderForThreadModel(input: {176function inferProviderForThreadModel(input: {
167 readonly model: string;177 readonly model: string;
168 readonly sessionProviderName: string | null;178 readonly sessionProviderName: string | null;
169}): "codex" | "claudeCode" {179}): ProviderKind {
170 if (input.sessionProviderName === "codex" || input.sessionProviderName === "claudeCode") {180 if (
181 input.sessionProviderName === "codex" ||
182 input.sessionProviderName === "claudeCode" ||
183 input.sessionProviderName === "cursor"
184 ) {
171 return input.sessionProviderName;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 const normalizedClaude = normalizeModelSlug(input.model, "claudeCode");191 const normalizedClaude = normalizeModelSlug(input.model, "claudeCode");
174 if (normalizedClaude && CLAUDE_MODEL_SLUGS.has(normalizedClaude)) {192 if (normalizedClaude && CLAUDE_MODEL_SLUGS.has(normalizedClaude)) {
175 return "claudeCode";193 return "claudeCode";
...@@ -178,6 +196,13 @@ function inferProviderForThreadModel(input: {...@@ -178,6 +196,13 @@ function inferProviderForThreadModel(input: {
178 if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) {196 if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) {
179 return "codex";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 return input.model.trim().startsWith("claude-") ? "claudeCode" : "codex";206 return input.model.trim().startsWith("claude-") ? "claudeCode" : "codex";
182}207}
183208
packages/contracts/src/model.test.ts+73
...@@ -1,15 +1,20 @@...@@ -1,15 +1,20 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import {3import {
4 CURSOR_MODEL_FAMILY_OPTIONS,
5 CURSOR_REASONING_OPTIONS,
4 DEFAULT_MODEL,6 DEFAULT_MODEL,
5 DEFAULT_MODEL_BY_PROVIDER,7 DEFAULT_MODEL_BY_PROVIDER,
6 MODEL_OPTIONS,8 MODEL_OPTIONS,
7 MODEL_OPTIONS_BY_PROVIDER,9 MODEL_OPTIONS_BY_PROVIDER,
8 getDefaultModel,10 getDefaultModel,
11 getCursorModelFamilyOptions,
9 getModelOptions,12 getModelOptions,
10 getReasoningOptions,13 getReasoningOptions,
11 normalizeModelSlug,14 normalizeModelSlug,
12 REASONING_OPTIONS,15 REASONING_OPTIONS,
16 parseCursorModelSelection,
17 resolveCursorModelFromSelection,
13 resolveModelSlug,18 resolveModelSlug,
14 resolveModelSlugForProvider,19 resolveModelSlugForProvider,
15} from "./model";20} from "./model";
...@@ -38,6 +43,14 @@ describe("normalizeModelSlug", () => {...@@ -38,6 +43,14 @@ describe("normalizeModelSlug", () => {
38 expect(normalizeModelSlug("claude-haiku-4-5-20251001", "claudeCode")).toBe(43 expect(normalizeModelSlug("claude-haiku-4-5-20251001", "claudeCode")).toBe(
39 "claude-haiku-4-5",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});
4356
...@@ -66,12 +79,72 @@ describe("resolveModelSlug", () => {...@@ -66,12 +79,72 @@ describe("resolveModelSlug", () => {
66 expect(resolveModelSlugForProvider("claudeCode", "gpt-5.3-codex")).toBe(79 expect(resolveModelSlugForProvider("claudeCode", "gpt-5.3-codex")).toBe(
67 DEFAULT_MODEL_BY_PROVIDER.claudeCode,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 });
7093
71 it("keeps codex defaults for backward compatibility", () => {94 it("keeps codex defaults for backward compatibility", () => {
72 expect(getDefaultModel()).toBe(DEFAULT_MODEL);95 expect(getDefaultModel()).toBe(DEFAULT_MODEL);
73 expect(getModelOptions()).toEqual(MODEL_OPTIONS);96 expect(getModelOptions()).toEqual(MODEL_OPTIONS);
74 expect(getModelOptions("claudeCode")).toEqual(MODEL_OPTIONS_BY_PROVIDER.claudeCode);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
103describe("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});
77150
packages/contracts/src/model.ts+393-5
...@@ -5,6 +5,39 @@ type ModelOption = {...@@ -5,6 +5,39 @@ type ModelOption = {
5 readonly name: string;5 readonly name: string;
6};6};
77
8export const CURSOR_REASONING_OPTIONS = ["low", "normal", "high", "xhigh"] as const;
9export type CursorReasoningOption = (typeof CURSOR_REASONING_OPTIONS)[number];
10
11type CursorModelFamilyOption = {
12 readonly slug: string;
13 readonly name: string;
14};
15
16export 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
39export type CursorModelFamily = (typeof CURSOR_MODEL_FAMILY_OPTIONS)[number]["slug"];
40
8export const MODEL_OPTIONS_BY_PROVIDER = {41export const MODEL_OPTIONS_BY_PROVIDER = {
9 codex: [42 codex: [
10 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },43 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
...@@ -17,13 +50,218 @@ export const MODEL_OPTIONS_BY_PROVIDER = {...@@ -17,13 +50,218 @@ export const MODEL_OPTIONS_BY_PROVIDER = {
17 { slug: "claude-opus-4-6", name: "Claude Opus 4.6" },50 { slug: "claude-opus-4-6", name: "Claude Opus 4.6" },
18 { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" },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} as const satisfies Record<ProviderKind, readonly ModelOption[]>;94} as const satisfies Record<ProviderKind, readonly ModelOption[]>;
2195
22export type ModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"];96export type ModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"];
97export type CursorModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)["cursor"][number]["slug"];
98
99type CursorModelCapability = {
100 readonly supportsReasoning: boolean;
101 readonly supportsFast: boolean;
102 readonly supportsThinking: boolean;
103 readonly defaultReasoning: CursorReasoningOption;
104 readonly defaultThinking: boolean;
105};
106
107const 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
250const CURSOR_MODEL_FAMILY_SET = new Set<CursorModelFamily>(
251 CURSOR_MODEL_FAMILY_OPTIONS.map((option) => option.slug),
252);
253
254export interface CursorModelSelection {
255 readonly family: CursorModelFamily;
256 readonly reasoning: CursorReasoningOption;
257 readonly fast: boolean;
258 readonly thinking: boolean;
259}
23260
24export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, ModelSlug> = {261export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, ModelSlug> = {
25 codex: "gpt-5.3-codex",262 codex: "gpt-5.3-codex",
26 claudeCode: "claude-sonnet-4-6",263 claudeCode: "claude-sonnet-4-6",
264 cursor: "opus-4.6-thinking",
27};265};
28266
29// Backward compatibility for existing Codex-only call sites.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,30 +276,180 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string,
38 "gpt-5.3-spark": "gpt-5.3-codex-spark",276 "gpt-5.3-spark": "gpt-5.3-codex-spark",
39 },277 },
40 claudeCode: {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 opus: "claude-opus-4-6",279 opus: "claude-opus-4-6",
46 "opus-4.6": "claude-opus-4-6",280 "opus-4.6": "claude-opus-4-6",
47 "claude-opus-4.6": "claude-opus-4-6",281 "claude-opus-4.6": "claude-opus-4-6",
48 "claude-opus-4-6-20251117": "claude-opus-4-6",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 haiku: "claude-haiku-4-5",287 haiku: "claude-haiku-4-5",
50 "haiku-4.5": "claude-haiku-4-5",288 "haiku-4.5": "claude-haiku-4-5",
51 "claude-haiku-4.5": "claude-haiku-4-5",289 "claude-haiku-4.5": "claude-haiku-4-5",
52 "claude-haiku-4-5-20251001": "claude-haiku-4-5",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};
55314
56const MODEL_SLUG_SET_BY_PROVIDER: Record<ProviderKind, ReadonlySet<ModelSlug>> = {315const MODEL_SLUG_SET_BY_PROVIDER: Record<ProviderKind, ReadonlySet<ModelSlug>> = {
57 codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)),
58 claudeCode: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeCode.map((option) => option.slug)),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};
60320
61export function getModelOptions(provider: ProviderKind = "codex") {321export function getModelOptions(provider: ProviderKind = "codex") {
62 return MODEL_OPTIONS_BY_PROVIDER[provider];322 return MODEL_OPTIONS_BY_PROVIDER[provider];
63}323}
64324
325export function getCursorModelFamilyOptions() {
326 return CURSOR_MODEL_FAMILY_OPTIONS;
327}
328
329export function getCursorModelCapabilities(family: CursorModelFamily) {
330 return CURSOR_MODEL_CAPABILITY_BY_FAMILY[family];
331}
332
333function fallbackCursorModelFamily(): CursorModelFamily {
334 const fallback = parseCursorModelSelection(DEFAULT_MODEL_BY_PROVIDER.cursor);
335 return fallback.family;
336}
337
338function 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
388function 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
395export 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
426export 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
65export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug {453export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug {
66 return DEFAULT_MODEL_BY_PROVIDER[provider];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,6 +138,25 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", (
138 }),138 }),
139);139);
140140
141it.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
141it.effect("decodes thread.turn-start-requested defaults for provider and runtime mode", () =>160it.effect("decodes thread.turn-start-requested defaults for provider and runtime mode", () =>
142 Effect.gen(function* () {161 Effect.gen(function* () {
143 const parsed = yield* decodeThreadTurnStartRequestedPayload({162 const parsed = yield* decodeThreadTurnStartRequestedPayload({
packages/contracts/src/orchestration.ts+1-1
...@@ -29,7 +29,7 @@ export const ORCHESTRATION_WS_CHANNELS = {...@@ -29,7 +29,7 @@ export const ORCHESTRATION_WS_CHANNELS = {
29 domainEvent: "orchestration.domainEvent",29 domainEvent: "orchestration.domainEvent",
30} as const;30} as const;
3131
32export const ProviderKind = Schema.Literals(["codex", "claudeCode"]);32export const ProviderKind = Schema.Literals(["codex", "claudeCode", "cursor"]);
33export type ProviderKind = typeof ProviderKind.Type;33export type ProviderKind = typeof ProviderKind.Type;
34export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";34export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";
35export const ProviderApprovalPolicy = Schema.Literals([35export const ProviderApprovalPolicy = Schema.Literals([
packages/contracts/src/provider.test.ts+14
...@@ -46,4 +46,18 @@ describe("ProviderSessionStartInput", () => {...@@ -46,4 +46,18 @@ describe("ProviderSessionStartInput", () => {
46 expect(parsed.approvalPolicy).toBe("never");46 expect(parsed.approvalPolicy).toBe("never");
47 expect(parsed.sandboxMode).toBe("danger-full-access");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});