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", () => {
9191 typeof input === "object" &&
9292 input !== null &&
9393 "provider" in input &&
94 (input.provider === "codex" || input.provider === "claudeCode")
94 (input.provider === "codex" ||
95 input.provider === "claudeCode" ||
96 input.provider === "cursor")
9597 ? input.provider
9698 : "codex";
9799 const resumeCursor =
......@@ -285,6 +287,44 @@ describe("ProviderCommandReactor", () => {
285287 expect(thread?.session?.providerSessionId).toBe("sess-1");
286288 });
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
288328 it("reuses the same provider session when runtime mode is unchanged", async () => {
289329 const harness = await createHarness();
290330 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+3-1
......@@ -184,7 +184,9 @@ const make = Effect.gen(function* () {
184184 const desiredSandboxMode =
185185 options?.sandboxMode ?? thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE;
186186 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"
188190 ? thread.session.providerName
189191 : undefined;
190192 const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider;
apps/server/src/persistence/Layers/ProviderSessions.ts+1-1
......@@ -13,7 +13,7 @@ import {
1313 type ProviderSessionRepositoryShape,
1414} from "../Services/ProviderSessions.ts";
1515
16const ProviderKind = Schema.Literals(["codex", "claudeCode"]);
16const ProviderKind = Schema.Literals(["codex", "claudeCode", "cursor"]);
1717
1818const ProviderSessionRowSchema = Schema.Struct({
1919 sessionId: ProviderSessionId,
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+18
......@@ -225,4 +225,22 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
225225
226226 fs.rmSync(tempDir, { recursive: true, force: true });
227227 }));
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 }));
228246});
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+1-1
......@@ -26,7 +26,7 @@ function decodeProviderKind(
2626 providerName: string,
2727 operation: string,
2828): Effect.Effect<ProviderKind, ProviderSessionDirectoryPersistenceError> {
29 if (providerName === "codex" || providerName === "claudeCode") {
29 if (providerName === "codex" || providerName === "claudeCode" || providerName === "cursor") {
3030 return Effect.succeed(providerName);
3131 }
3232 return Effect.fail(
apps/web/src/components/ChatView.tsx+334-71
......@@ -1,17 +1,23 @@
11import {
22 type ApprovalRequestId,
3 CURSOR_REASONING_OPTIONS,
34 DEFAULT_MODEL,
45 DEFAULT_REASONING,
56 EDITORS,
67 type EditorId,
78 type KeybindingCommand,
9 type CursorReasoningOption,
810 type MessageId,
911 getDefaultModel,
12 getCursorModelCapabilities,
13 getCursorModelFamilyOptions,
1014 getModelOptions,
1115 getReasoningOptions,
1216 type ProjectId,
1317 type ProjectEntry,
1418 type ProjectScript,
19 type ModelSlug,
20 parseCursorModelSelection,
1521 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1622 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
1723 type ReasoningEffort,
......@@ -22,6 +28,7 @@ import {
2228 type ThreadId,
2329 type TurnId,
2430 normalizeModelSlug,
31 resolveCursorModelFromSelection,
2532 resolveModelSlugForProvider,
2633 OrchestrationThreadActivity,
2734} from "@t3tools/contracts";
......@@ -109,8 +116,6 @@ import {
109116import { Button } from "./ui/button";
110117import {
111118 Select,
112 SelectGroup,
113 SelectGroupLabel,
114119 SelectItem,
115120 SelectPopup,
116121 SelectTrigger,
......@@ -118,8 +123,21 @@ import {
118123} from "./ui/select";
119124import { Separator } from "./ui/separator";
120125import { Group, GroupSeparator } from "./ui/group";
121import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";
122import { ClaudeAI, CursorIcon, Icon, OpenAI } from "./Icons";
126import {
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";
123141import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
124142import { Badge } from "./ui/badge";
125143import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
......@@ -525,6 +543,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
525543 const [selectedProviderByThread, setSelectedProviderByThread] = useState<
526544 Partial<Record<ThreadId, ProviderKind>>
527545 >({});
546 const [selectedModelByThread, setSelectedModelByThread] = useState<
547 Partial<Record<ThreadId, ModelSlug>>
548 >({});
528549 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
529550 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
530551 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
......@@ -655,16 +676,37 @@ export default function ChatView({ threadId }: ChatViewProps) {
655676 "codex";
656677 const baseThreadModel = resolveModelSlugForProvider(
657678 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,
659684 );
660685 const selectedModel = resolveModelSlugForProvider(selectedProvider, composerDraft.model ?? baseThreadModel);
661686 const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING;
662687 const reasoningOptions = getReasoningOptions(selectedProvider);
663688 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;
664706 const searchableModelOptions = useMemo(
665707 () =>
666708 PROVIDER_OPTIONS.filter((option) => option.available).flatMap((option) =>
667 getModelOptions(option.value).map(({ slug, name }) => ({
709 getProviderModelPickerOptions(option.value).map(({ slug, name }) => ({
668710 provider: option.value,
669711 providerLabel: option.label,
670712 slug,
......@@ -683,6 +725,22 @@ export default function ChatView({ threadId }: ChatViewProps) {
683725 return { ...existing, [activeThread.id]: sessionProvider };
684726 });
685727 }, [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]);
686744 const phase = derivePhase(activeThread?.session ?? null);
687745 const isSendBusy = sendPhase !== "idle";
688746 const isPreparingWorktree = sendPhase === "preparing-worktree";
......@@ -2261,22 +2319,72 @@ export default function ChatView({ threadId }: ChatViewProps) {
22612319 (provider: ProviderKind, model: ModelSlug) => {
22622320 const api = readNativeApi();
22632321 if (!activeThread) return;
2322 const resolvedModel =
2323 provider === "cursor"
2324 ? resolveCursorModelFromSelection(parseCursorModelSelection(model))
2325 : resolveModelSlugForProvider(provider, model);
22642326 setSelectedProviderByThread((existing) => ({
22652327 ...existing,
22662328 [activeThread.id]: provider,
22672329 }));
2330 setSelectedModelByThread((existing) => ({
2331 ...existing,
2332 [activeThread.id]: resolvedModel,
2333 }));
22682334 if (api) {
22692335 void api.orchestration.dispatchCommand({
22702336 type: "thread.meta.update",
22712337 commandId: newCommandId(),
22722338 threadId: activeThread.id,
2273 model: resolveModelSlugForProvider(provider, model),
2339 model: resolvedModel,
22742340 });
22752341 }
22762342 scheduleComposerFocus();
22772343 },
22782344 [activeThread, scheduleComposerFocus],
22792345 );
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 );
22802388 const onEffortSelect = useCallback(
22812389 (effort: ReasoningEffort) => {
22822390 setComposerDraftEffort(threadId, effort);
......@@ -2716,26 +2824,43 @@ export default function ChatView({ threadId }: ChatViewProps) {
27162824 {/* Provider/model picker */}
27172825 <ProviderModelPicker
27182826 provider={selectedProvider}
2719 model={selectedModel}
2827 model={selectedModelForPicker}
27202828 onProviderModelChange={onProviderModelSelect}
27212829 />
27222830
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 )}
27252836
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 ? (
27282848 <>
2849 {/* Divider */}
2850 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
2851
2852 {/* Reasoning effort */}
27292853 <ReasoningEffortPicker
27302854 effort={selectedEffort}
27312855 options={reasoningOptions}
27322856 onEffortChange={onEffortSelect}
27332857 />
2734 {/* Divider */}
2735 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
27362858 </>
27372859 ) : null}
27382860
2861 {/* Divider */}
2862 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
2863
27392864 {/* Runtime mode toggle */}
27402865 <Button
27412866 variant="ghost"
......@@ -3868,87 +3993,141 @@ const MessagesTimeline = memo(function MessagesTimeline({
38683993 );
38693994});
38703995
3871const 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}));
3996const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => option.available);
3997const COMING_SOON_PROVIDER_OPTIONS = [
3998 { id: "opencode", label: "OpenCode", icon: OpenCodeIcon },
3999 { id: "gemini", label: "Gemini", icon: Gemini },
4000] as const;
4001
4002function getProviderModelPickerOptions(provider: ProviderKind) {
4003 if (provider === "cursor") {
4004 return getCursorModelFamilyOptions();
4005 }
4006 return getModelOptions(provider);
4007}
38804008
38814009const PROVIDER_ICON_BY_PROVIDER: Record<ProviderKind, Icon> = {
38824010 codex: OpenAI,
38834011 claudeCode: ClaudeAI,
4012 cursor: CursorIcon,
38844013};
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
38864052const ProviderModelPicker = memo(function ProviderModelPicker(props: {
38874053 provider: ProviderKind;
38884054 model: ModelSlug;
38894055 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
38904056}) {
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);
38974059 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;
39014061 const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider];
39024062
39034063 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 >
39284068 <span className="flex min-w-0 items-center gap-2">
39294069 <ProviderIcon
39304070 aria-hidden="true"
39314071 className={cn(
39324072 "size-4 shrink-0",
3933 props.provider === "claudeCode" ? "" : "text-foreground/85",
4073 props.provider === "claudeCode" ? "" : "text-foreground/80",
39344074 )}
39354075 />
39364076 <span className="truncate">{selectedModelLabel}</span>
4077 <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" />
39374078 </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>
39524131 );
39534132});
39544133
......@@ -3977,6 +4156,90 @@ const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: {
39774156 );
39784157});
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
39804243const OpenInPicker = memo(function OpenInPicker({
39814244 keybindings,
39824245 availableEditors,
apps/web/src/components/Icons.tsx+182
......@@ -34,3 +34,185 @@ export const ClaudeAI: Icon = (props) => (
3434 />
3535 </svg>
3636);
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", () => {
235235 available: true,
236236 });
237237 });
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 });
238247});
apps/web/src/session-logic.ts+1
......@@ -15,6 +15,7 @@ export const PROVIDER_OPTIONS: Array<{
1515}> = [
1616 { value: "codex", label: "Codex", available: true },
1717 { value: "claudeCode", label: "Claude Code", available: true },
18 { value: "cursor", label: "Cursor Agent", available: true },
1819];
1920
2021export interface WorkLogEntry {
apps/web/src/store.test.ts+26
......@@ -161,4 +161,30 @@ describe("store read model sync", () => {
161161
162162 expect(next.threads[0]?.model).toBe("claude-sonnet-4-6");
163163 });
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 });
164190});
apps/web/src/store.ts+29-4
......@@ -3,6 +3,7 @@ import {
33 DEFAULT_MODEL,
44 getModelOptions,
55 normalizeModelSlug,
6 type ProviderKind,
67 ProviderSessionId,
78 ThreadId,
89 type OrchestrationReadModel,
......@@ -156,20 +157,37 @@ function toLegacySessionStatus(
156157 }
157158}
158159
159function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" {
160 return providerName === "claudeCode" ? "claudeCode" : "codex";
160function toLegacyProvider(providerName: string | null): ProviderKind {
161 if (providerName === "codex" || providerName === "claudeCode" || providerName === "cursor") {
162 return providerName;
163 }
164 return "codex";
161165}
162166
163167const CODEX_MODEL_SLUGS = new Set(getModelOptions("codex").map((option) => option.slug));
164168const 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
166176function inferProviderForThreadModel(input: {
167177 readonly model: string;
168178 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 ) {
171185 return input.sessionProviderName;
172186 }
187 const normalizedCursor = normalizeModelSlug(input.model, "cursor");
188 if (normalizedCursor && CURSOR_DISTINCT_MODEL_SLUGS.has(normalizedCursor)) {
189 return "cursor";
190 }
173191 const normalizedClaude = normalizeModelSlug(input.model, "claudeCode");
174192 if (normalizedClaude && CLAUDE_MODEL_SLUGS.has(normalizedClaude)) {
175193 return "claudeCode";
......@@ -178,6 +196,13 @@ function inferProviderForThreadModel(input: {
178196 if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) {
179197 return "codex";
180198 }
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 }
181206 return input.model.trim().startsWith("claude-") ? "claudeCode" : "codex";
182207}
183208
packages/contracts/src/model.test.ts+73
......@@ -1,15 +1,20 @@
11import { describe, expect, it } from "vitest";
22
33import {
4 CURSOR_MODEL_FAMILY_OPTIONS,
5 CURSOR_REASONING_OPTIONS,
46 DEFAULT_MODEL,
57 DEFAULT_MODEL_BY_PROVIDER,
68 MODEL_OPTIONS,
79 MODEL_OPTIONS_BY_PROVIDER,
810 getDefaultModel,
11 getCursorModelFamilyOptions,
912 getModelOptions,
1013 getReasoningOptions,
1114 normalizeModelSlug,
1215 REASONING_OPTIONS,
16 parseCursorModelSelection,
17 resolveCursorModelFromSelection,
1318 resolveModelSlug,
1419 resolveModelSlugForProvider,
1520} from "./model";
......@@ -38,6 +43,14 @@ describe("normalizeModelSlug", () => {
3843 expect(normalizeModelSlug("claude-haiku-4-5-20251001", "claudeCode")).toBe(
3944 "claude-haiku-4-5",
4045 );
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 );
4154 });
4255});
4356
......@@ -66,12 +79,72 @@ describe("resolveModelSlug", () => {
6679 expect(resolveModelSlugForProvider("claudeCode", "gpt-5.3-codex")).toBe(
6780 DEFAULT_MODEL_BY_PROVIDER.claudeCode,
6881 );
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 );
6992 });
7093
7194 it("keeps codex defaults for backward compatibility", () => {
7295 expect(getDefaultModel()).toBe(DEFAULT_MODEL);
7396 expect(getModelOptions()).toEqual(MODEL_OPTIONS);
7497 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");
75148 });
76149});
77150
packages/contracts/src/model.ts+393-5
......@@ -5,6 +5,39 @@ type ModelOption = {
55 readonly name: string;
66};
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
841export const MODEL_OPTIONS_BY_PROVIDER = {
942 codex: [
1043 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
......@@ -17,13 +50,218 @@ export const MODEL_OPTIONS_BY_PROVIDER = {
1750 { slug: "claude-opus-4-6", name: "Claude Opus 4.6" },
1851 { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" },
1952 ],
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 ],
2094} as const satisfies Record<ProviderKind, readonly ModelOption[]>;
2195
2296export 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
24261export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, ModelSlug> = {
25262 codex: "gpt-5.3-codex",
26263 claudeCode: "claude-sonnet-4-6",
264 cursor: "opus-4.6-thinking",
27265};
28266
29267// Backward compatibility for existing Codex-only call sites.
......@@ -38,30 +276,180 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string,
38276 "gpt-5.3-spark": "gpt-5.3-codex-spark",
39277 },
40278 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",
45279 opus: "claude-opus-4-6",
46280 "opus-4.6": "claude-opus-4-6",
47281 "claude-opus-4.6": "claude-opus-4-6",
48282 "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",
49287 haiku: "claude-haiku-4-5",
50288 "haiku-4.5": "claude-haiku-4-5",
51289 "claude-haiku-4.5": "claude-haiku-4-5",
52290 "claude-haiku-4-5-20251001": "claude-haiku-4-5",
53291 },
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 },
54313};
55314
56315const MODEL_SLUG_SET_BY_PROVIDER: Record<ProviderKind, ReadonlySet<ModelSlug>> = {
57 codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)),
58316 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)),
59319};
60320
61321export function getModelOptions(provider: ProviderKind = "codex") {
62322 return MODEL_OPTIONS_BY_PROVIDER[provider];
63323}
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
65453export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug {
66454 return DEFAULT_MODEL_BY_PROVIDER[provider];
67455}
packages/contracts/src/orchestration.test.ts+19
......@@ -138,6 +138,25 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", (
138138 }),
139139);
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
141160it.effect("decodes thread.turn-start-requested defaults for provider and runtime mode", () =>
142161 Effect.gen(function* () {
143162 const parsed = yield* decodeThreadTurnStartRequestedPayload({
packages/contracts/src/orchestration.ts+1-1
......@@ -29,7 +29,7 @@ export const ORCHESTRATION_WS_CHANNELS = {
2929 domainEvent: "orchestration.domainEvent",
3030} as const;
3131
32export const ProviderKind = Schema.Literals(["codex", "claudeCode"]);
32export const ProviderKind = Schema.Literals(["codex", "claudeCode", "cursor"]);
3333export type ProviderKind = typeof ProviderKind.Type;
3434export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";
3535export const ProviderApprovalPolicy = Schema.Literals([
packages/contracts/src/provider.test.ts+14
......@@ -46,4 +46,18 @@ describe("ProviderSessionStartInput", () => {
4646 expect(parsed.approvalPolicy).toBe("never");
4747 expect(parsed.sandboxMode).toBe("danger-full-access");
4848 });
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 });
4963});