From 86e204439e5d065fe1063a2a28df80863528867a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Feb 2026 10:27:26 -0800 Subject: [PATCH] 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 --- .../Layers/ProviderCommandReactor.test.ts | 42 +- .../Layers/ProviderCommandReactor.ts | 4 +- .../persistence/Layers/ProviderSessions.ts | 2 +- .../Layers/ProviderSessionDirectory.test.ts | 18 + .../Layers/ProviderSessionDirectory.ts | 2 +- apps/web/src/components/ChatView.tsx | 405 +++++++++++++++--- apps/web/src/components/Icons.tsx | 182 ++++++++ apps/web/src/session-logic.test.ts | 9 + apps/web/src/session-logic.ts | 1 + apps/web/src/store.test.ts | 26 ++ apps/web/src/store.ts | 33 +- packages/contracts/src/model.test.ts | 73 ++++ packages/contracts/src/model.ts | 398 ++++++++++++++++- packages/contracts/src/orchestration.test.ts | 19 + packages/contracts/src/orchestration.ts | 2 +- packages/contracts/src/provider.test.ts | 14 + 16 files changed, 1145 insertions(+), 85 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 75b8e397ecc1d1bc9cfa4cd4cc8613840721c261..eece7658292becf34f719617836b5336b6e204c8 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -91,7 +91,9 @@ describe("ProviderCommandReactor", () => { typeof input === "object" && input !== null && "provider" in input && - (input.provider === "codex" || input.provider === "claudeCode") + (input.provider === "codex" || + input.provider === "claudeCode" || + input.provider === "cursor") ? input.provider : "codex"; const resumeCursor = @@ -285,6 +287,44 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.providerSessionId).toBe("sess-1"); }); + it("starts first turn with cursor provider when provider is specified", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-provider-cursor"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-provider-cursor"), + role: "user", + text: "hello cursor", + attachments: [], + }, + provider: "cursor", + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + provider: "cursor", + cwd: "/tmp/provider-project", + model: "gpt-5-codex", + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + }); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.session?.providerName).toBe("cursor"); + expect(thread?.session?.providerSessionId).toBe("sess-1"); + }); + it("reuses the same provider session when runtime mode is unchanged", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index ca075215e62b93f5f060fbd319813b1ca8e2b04b..d2cbf152fdd2dc17d4587966e83a56710da07552 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -184,7 +184,9 @@ const make = Effect.gen(function* () { const desiredSandboxMode = options?.sandboxMode ?? thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE; const currentProvider: ProviderKind | undefined = - thread.session?.providerName === "codex" || thread.session?.providerName === "claudeCode" + thread.session?.providerName === "codex" || + thread.session?.providerName === "claudeCode" || + thread.session?.providerName === "cursor" ? thread.session.providerName : undefined; const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider; diff --git a/apps/server/src/persistence/Layers/ProviderSessions.ts b/apps/server/src/persistence/Layers/ProviderSessions.ts index eb868fa20c2ed690f955b1692a8793332b42fad4..ffdb6868b42eb18f9875d6e2284bc136c2963065 100644 --- a/apps/server/src/persistence/Layers/ProviderSessions.ts +++ b/apps/server/src/persistence/Layers/ProviderSessions.ts @@ -13,7 +13,7 @@ import { type ProviderSessionRepositoryShape, } from "../Services/ProviderSessions.ts"; -const ProviderKind = Schema.Literals(["codex", "claudeCode"]); +const ProviderKind = Schema.Literals(["codex", "claudeCode", "cursor"]); const ProviderSessionRowSchema = Schema.Struct({ sessionId: ProviderSessionId, diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index cd43805682ebff6b897e80943f237a93673c465f..585b6ac089b2f0aada5ea5ede14d15a5d6097a73 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -225,4 +225,22 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL fs.rmSync(tempDir, { recursive: true, force: true }); })); + + it("accepts cursor provider bindings", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const sessionId = ProviderSessionId.makeUnsafe("sess-cursor"); + const threadId = ThreadId.makeUnsafe("thread-cursor"); + + yield* directory.upsert({ + sessionId, + provider: "cursor", + threadId, + }); + + const provider = yield* directory.getProvider(sessionId); + assert.equal(provider, "cursor"); + const resolvedThreadId = yield* directory.getThreadId(sessionId); + assertSome(resolvedThreadId, threadId); + })); }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 1b096e91e0edc686b7243d6a9cea28d7c2fbd3cf..b95f40526d3b70e09163bd3f0047ef3e367e29d7 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -26,7 +26,7 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex" || providerName === "claudeCode") { + if (providerName === "codex" || providerName === "claudeCode" || providerName === "cursor") { return Effect.succeed(providerName); } return Effect.fail( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 37bc20b5418be8bf127145241d2921506fdd2a7c..e6ce135e0dd07e051cef7feed0fecd589587ecea 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,17 +1,23 @@ import { type ApprovalRequestId, + CURSOR_REASONING_OPTIONS, DEFAULT_MODEL, DEFAULT_REASONING, EDITORS, type EditorId, type KeybindingCommand, + type CursorReasoningOption, type MessageId, getDefaultModel, + getCursorModelCapabilities, + getCursorModelFamilyOptions, getModelOptions, getReasoningOptions, type ProjectId, type ProjectEntry, type ProjectScript, + type ModelSlug, + parseCursorModelSelection, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ReasoningEffort, @@ -22,6 +28,7 @@ import { type ThreadId, type TurnId, normalizeModelSlug, + resolveCursorModelFromSelection, resolveModelSlugForProvider, OrchestrationThreadActivity, } from "@t3tools/contracts"; @@ -109,8 +116,6 @@ import { import { Button } from "./ui/button"; import { Select, - SelectGroup, - SelectGroupLabel, SelectItem, SelectPopup, SelectTrigger, @@ -118,8 +123,21 @@ import { } from "./ui/select"; import { Separator } from "./ui/separator"; import { Group, GroupSeparator } from "./ui/group"; -import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"; -import { ClaudeAI, CursorIcon, Icon, OpenAI } from "./Icons"; +import { + Menu, + MenuGroup, + MenuItem, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator as MenuDivider, + MenuSub, + MenuSubPopup, + MenuSubTrigger, + MenuShortcut, + MenuTrigger, +} from "./ui/menu"; +import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "./Icons"; import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { Badge } from "./ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -525,6 +543,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const [selectedProviderByThread, setSelectedProviderByThread] = useState< Partial> >({}); + const [selectedModelByThread, setSelectedModelByThread] = useState< + Partial> + >({}); const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState([]); const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); @@ -655,16 +676,37 @@ export default function ChatView({ threadId }: ChatViewProps) { "codex"; const baseThreadModel = resolveModelSlugForProvider( selectedProvider, - activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider) ?? DEFAULT_MODEL, + (activeThread?.id ? selectedModelByThread[activeThread.id] : undefined) ?? + activeThread?.model ?? + activeProject?.model ?? + getDefaultModel(selectedProvider) ?? + DEFAULT_MODEL, ); const selectedModel = resolveModelSlugForProvider(selectedProvider, composerDraft.model ?? baseThreadModel); const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING; const reasoningOptions = getReasoningOptions(selectedProvider); const supportsReasoningEffort = reasoningOptions.length > 0; + const selectedCursorModel = useMemo( + () => (selectedProvider === "cursor" ? parseCursorModelSelection(selectedModel) : null), + [selectedModel, selectedProvider], + ); + const selectedCursorModelCapabilities = useMemo( + () => + selectedCursorModel ? getCursorModelCapabilities(selectedCursorModel.family) : null, + [selectedCursorModel], + ); + const hasSelectedCursorTraits = Boolean( + selectedCursorModelCapabilities && + (selectedCursorModelCapabilities.supportsReasoning || + selectedCursorModelCapabilities.supportsFast || + selectedCursorModelCapabilities.supportsThinking), + ); + const selectedModelForPicker = + selectedProvider === "cursor" && selectedCursorModel ? selectedCursorModel.family : selectedModel; const searchableModelOptions = useMemo( () => PROVIDER_OPTIONS.filter((option) => option.available).flatMap((option) => - getModelOptions(option.value).map(({ slug, name }) => ({ + getProviderModelPickerOptions(option.value).map(({ slug, name }) => ({ provider: option.value, providerLabel: option.label, slug, @@ -683,6 +725,22 @@ export default function ChatView({ threadId }: ChatViewProps) { return { ...existing, [activeThread.id]: sessionProvider }; }); }, [activeThread?.id, sessionProvider]); + useEffect(() => { + if (!activeThread?.id) return; + setSelectedModelByThread((existing) => { + if (existing[activeThread.id]) return existing; + return { + ...existing, + [activeThread.id]: resolveModelSlugForProvider( + selectedProvider, + activeThread.model ?? + activeProject?.model ?? + getDefaultModel(selectedProvider) ?? + DEFAULT_MODEL, + ), + }; + }); + }, [activeProject?.model, activeThread?.id, activeThread?.model, selectedProvider]); const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; const isPreparingWorktree = sendPhase === "preparing-worktree"; @@ -2261,22 +2319,72 @@ export default function ChatView({ threadId }: ChatViewProps) { (provider: ProviderKind, model: ModelSlug) => { const api = readNativeApi(); if (!activeThread) return; + const resolvedModel = + provider === "cursor" + ? resolveCursorModelFromSelection(parseCursorModelSelection(model)) + : resolveModelSlugForProvider(provider, model); setSelectedProviderByThread((existing) => ({ ...existing, [activeThread.id]: provider, })); + setSelectedModelByThread((existing) => ({ + ...existing, + [activeThread.id]: resolvedModel, + })); if (api) { void api.orchestration.dispatchCommand({ type: "thread.meta.update", commandId: newCommandId(), threadId: activeThread.id, - model: resolveModelSlugForProvider(provider, model), + model: resolvedModel, }); } scheduleComposerFocus(); }, [activeThread, scheduleComposerFocus], ); + const onCursorReasoningSelect = useCallback( + (reasoning: CursorReasoningOption) => { + if (selectedProvider !== "cursor") return; + const cursorSelection = parseCursorModelSelection(selectedModel); + const nextModel = resolveCursorModelFromSelection({ + family: cursorSelection.family, + reasoning, + fast: cursorSelection.fast, + thinking: cursorSelection.thinking, + }); + onProviderModelSelect("cursor", nextModel); + }, + [onProviderModelSelect, selectedModel, selectedProvider], + ); + const onCursorFastModeChange = useCallback( + (enabled: boolean) => { + if (selectedProvider !== "cursor") return; + const cursorSelection = parseCursorModelSelection(selectedModel); + const nextModel = resolveCursorModelFromSelection({ + family: cursorSelection.family, + reasoning: cursorSelection.reasoning, + fast: enabled, + thinking: cursorSelection.thinking, + }); + onProviderModelSelect("cursor", nextModel); + }, + [onProviderModelSelect, selectedModel, selectedProvider], + ); + const onCursorThinkingModeChange = useCallback( + (enabled: boolean) => { + if (selectedProvider !== "cursor") return; + const cursorSelection = parseCursorModelSelection(selectedModel); + const nextModel = resolveCursorModelFromSelection({ + family: cursorSelection.family, + reasoning: cursorSelection.reasoning, + fast: cursorSelection.fast, + thinking: enabled, + }); + onProviderModelSelect("cursor", nextModel); + }, + [onProviderModelSelect, selectedModel, selectedProvider], + ); const onEffortSelect = useCallback( (effort: ReasoningEffort) => { setComposerDraftEffort(threadId, effort); @@ -2716,26 +2824,43 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Provider/model picker */} - {/* Divider */} - + {selectedProvider === "cursor" ? ( + <> + {hasSelectedCursorTraits && ( + + )} - {/* Reasoning effort */} - {supportsReasoningEffort ? ( + {selectedCursorModel && selectedCursorModelCapabilities && hasSelectedCursorTraits && ( + + )} + + ) : supportsReasoningEffort ? ( <> + {/* Divider */} + + + {/* Reasoning effort */} - {/* Divider */} - ) : null} + {/* Divider */} + + {/* Runtime mode toggle */}