From ae5d04b2dbb024c19316f2f9664ded7b0f9b47dd Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 18 May 2026 04:48:09 -0400 Subject: [PATCH 01/14] WIP: droid SDK pool + ModelPicker overhaul (safety snapshot) Pre-merge snapshot to protect uncommitted work before pulling origin/main: - droidSdk{Pool,Worker,EventMapper,Protocol}.ts replace deleted droidAcpPool.ts - New apps/desktop/.../shared/ModelPicker/ folder (favorites + recents + providers split) - Removed ModelCatalogPanel.tsx and ProviderModelSelector.tsx (superseded) - Wires through agentChatService, AgentChatPane, AgentChatComposer, multiple settings/launch controls Will be replayed onto merged main; this commit may be squashed later. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/desktop/package-lock.json | 104 ++ apps/desktop/package.json | 3 + .../services/ai/tools/systemPrompt.test.ts | 6 +- .../main/services/ai/tools/systemPrompt.ts | 8 +- .../services/chat/agentChatService.test.ts | 449 ++----- .../main/services/chat/agentChatService.ts | 1139 ++++++----------- .../src/main/services/chat/droidAcpPool.ts | 153 --- .../chat/droidModelsDiscovery.test.ts | 116 +- .../services/chat/droidModelsDiscovery.ts | 62 +- .../main/services/chat/droidSdkEventMapper.ts | 259 ++++ .../src/main/services/chat/droidSdkPool.ts | 270 ++++ .../main/services/chat/droidSdkProtocol.ts | 119 ++ .../src/main/services/chat/droidSdkWorker.ts | 318 +++++ .../components/app/FeedbackReporterModal.tsx | 6 +- .../components/chat/AgentChatComposer.tsx | 104 +- .../components/chat/AgentChatPane.tsx | 9 +- .../components/cto/IdentityEditor.tsx | 6 +- .../components/cto/WorkerCreationWizard.tsx | 5 +- .../components/missions/ModelSelector.tsx | 15 +- .../shared/PrResolverLaunchControls.test.tsx | 8 +- .../prs/shared/PrResolverLaunchControls.tsx | 8 +- .../components/settings/AiFeaturesSection.tsx | 10 +- .../components/settings/ProvidersSection.tsx | 4 +- .../components/shared/ModelCatalogPanel.tsx | 886 ------------- .../shared/ModelPicker/ModelListRow.tsx | 311 +++++ .../shared/ModelPicker/ModelPicker.test.tsx | 479 +++++++ .../shared/ModelPicker/ModelPicker.tsx | 320 +++++ .../shared/ModelPicker/ModelPickerContent.tsx | 594 +++++++++ .../shared/ModelPicker/ModelPickerRail.tsx | 145 +++ .../ModelPicker/ReasoningEffortControl.tsx | 72 ++ .../shared/ModelPicker/modelCatalog.test.ts | 33 + .../shared/ModelPicker/modelCatalog.ts | 139 ++ .../shared/ModelPicker/modelOrdering.test.ts | 85 ++ .../shared/ModelPicker/modelOrdering.ts | 84 ++ .../ModelPicker/modelPickerSearch.test.ts | 144 +++ .../shared/ModelPicker/modelPickerSearch.ts | 180 +++ .../shared/ModelPicker/useAuthOnlyFilter.ts | 56 + .../shared/ModelPicker/useModelFavorites.ts | 57 + .../shared/ModelPicker/useModelRecents.ts | 71 + .../ModelPicker/usePerSurfaceModelDefaults.ts | 67 + .../ModelPicker/useProviderAuthStatus.ts | 82 ++ .../ModelPicker/useReasoningByFamily.ts | 73 ++ .../components/shared/ProviderLogos.tsx | 32 + .../shared/ProviderModelSelector.tsx | 329 ----- .../shared/ReviewLaunchModelControls.tsx | 10 +- .../terminals/WorkViewArea.test.tsx | 6 +- .../components/terminals/WorkViewArea.tsx | 134 +- apps/desktop/src/shared/modelRegistry.ts | 2 +- apps/desktop/tsup.config.ts | 3 +- 49 files changed, 4917 insertions(+), 2658 deletions(-) delete mode 100644 apps/desktop/src/main/services/chat/droidAcpPool.ts create mode 100644 apps/desktop/src/main/services/chat/droidSdkEventMapper.ts create mode 100644 apps/desktop/src/main/services/chat/droidSdkPool.ts create mode 100644 apps/desktop/src/main/services/chat/droidSdkProtocol.ts create mode 100644 apps/desktop/src/main/services/chat/droidSdkWorker.ts delete mode 100644 apps/desktop/src/renderer/components/shared/ModelCatalogPanel.tsx create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/ModelListRow.tsx create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerRail.tsx create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortControl.tsx create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/modelOrdering.test.ts create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/modelOrdering.ts create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/modelPickerSearch.test.ts create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/modelPickerSearch.ts create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/useAuthOnlyFilter.ts create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/useModelFavorites.ts create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.ts create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/usePerSurfaceModelDefaults.ts create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/useReasoningByFamily.ts delete mode 100644 apps/desktop/src/renderer/components/shared/ProviderModelSelector.tsx diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 3506745cd..71797efa3 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -12,6 +12,7 @@ "@agentclientprotocol/sdk": "^0.20.0", "@anthropic-ai/claude-agent-sdk": "^0.2.139", "@cursor/sdk": "^1.0.9", + "@factory/droid-sdk": "^0.2.0", "@floating-ui/react": "^0.27.19", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/space-grotesk": "^5.2.10", @@ -25,8 +26,10 @@ "@opencode-ai/sdk": "^1.4.2", "@phosphor-icons/react": "^2.1.10", "@pierre/diffs": "^1.1.21", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-virtual": "^3.13.21", "@types/canvas-confetti": "^1.9.0", @@ -2145,6 +2148,42 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@factory/droid-sdk": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@factory/droid-sdk/-/droid-sdk-0.2.0.tgz", + "integrity": "sha512-m8Srp98pTvu5jAZtZpX6/Ojut6KV3CiqUGh0MXBUcsuKzsDw8hODZEbPTocxP6MrT0rsoKpqCS9SRTt0m+9cqw==", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "uuid": "^11.1.0", + "zod": "^3.24.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@factory/droid-sdk/node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/@factory/droid-sdk/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@fastify/busboy": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", @@ -4079,6 +4118,34 @@ } } }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dialog": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", @@ -4284,6 +4351,43 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index dddf4e406..c8440866d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -52,6 +52,7 @@ "@agentclientprotocol/sdk": "^0.20.0", "@anthropic-ai/claude-agent-sdk": "^0.2.139", "@cursor/sdk": "^1.0.9", + "@factory/droid-sdk": "^0.2.0", "@floating-ui/react": "^0.27.19", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/space-grotesk": "^5.2.10", @@ -65,8 +66,10 @@ "@opencode-ai/sdk": "^1.4.2", "@phosphor-icons/react": "^2.1.10", "@pierre/diffs": "^1.1.21", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-virtual": "^3.13.21", "@types/canvas-confetti": "^1.9.0", diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts index 5aa0bdf4a..25b00ea50 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts @@ -131,9 +131,9 @@ describe("buildCodingAgentSystemPrompt", () => { expect(result).toContain("Cursor SDK"); }); - it("describes the Droid ACP runtime", () => { - const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "droid-acp" }); - expect(result).toContain("Factory Droid agent via ACP"); + it("describes the Droid SDK runtime", () => { + const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "droid-sdk" }); + expect(result).toContain("Factory Droid SDK"); }); it("describes the OpenCode runtime", () => { diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts index 77dd5eee3..f7bba945d 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts @@ -15,7 +15,7 @@ export type AdeRuntimeKind = | "codex-app-server" | "codex-cli" | "cursor-sdk" - | "droid-acp" + | "droid-sdk" | "opencode"; function describeRuntime(runtime: AdeRuntimeKind): string[] { @@ -41,10 +41,10 @@ function describeRuntime(runtime: AdeRuntimeKind): string[] { "**Runtime:** ADE Work chat hosted on the Cursor SDK (`@cursor/sdk`).", "**Wake-up semantics:** Each turn is driven by ADE through the SDK agent run. There is no autonomous wake; if you need to wait, use a shell `sleep` and surface results in the next user turn.", ]; - case "droid-acp": + case "droid-sdk": return [ - "**Runtime:** ADE Work chat wrapping the Factory Droid agent via ACP.", - "**Wake-up semantics:** Each turn is a discrete ACP `prompt` request. There is no autonomous wake; if you need to wait, use a shell `sleep` and surface results in the next user turn.", + "**Runtime:** ADE Work chat hosted on the Factory Droid SDK (`@factory/droid-sdk`) and backed by the local Droid CLI.", + "**Wake-up semantics:** Each turn is driven by ADE through the Droid SDK stream. There is no autonomous wake; if you need to wait, use a shell `sleep` and surface results in the next user turn.", ]; case "opencode": return [ diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 99164b5cf..f11c4be6b 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -56,8 +56,10 @@ const mockState = vi.hoisted(() => ({ droidAcquireCalls: [] as Array>, droidNewSessionCalls: [] as Array>, droidPromptCalls: [] as Array>, + droidSettingsUpdates: [] as Array>, droidPooled: null as any, droidPromptGate: null as Promise | null, + droidPromptError: null as unknown, emitCodexPayload(payload: Record) { mockState.codexLineHandler?.(JSON.stringify(payload)); }, @@ -461,7 +463,7 @@ vi.mock("../../../shared/chatTranscript", () => ({ vi.mock("./cursorSdkPool", () => ({ acquireCursorSdkConnection: vi.fn(async (args: Record) => { mockState.cursorSdkAcquireCalls.push(args); - const pooled = { + const pooled: any = { process: { exitCode: null, killed: false }, bridge: { onEvent: null as any, @@ -531,43 +533,54 @@ vi.mock("./cursorSdkPool", () => ({ }), })); -vi.mock("./droidAcpPool", () => ({ - acquireDroidAcpConnection: vi.fn(async (args: Record) => { +vi.mock("./droidSdkPool", () => ({ + acquireDroidSdkConnection: vi.fn(async (args: Record) => { mockState.droidAcquireCalls.push(args); + mockState.droidSessionCounter += 1; + const sdkSessionId = typeof args.resumeSessionId === "string" && args.resumeSessionId.length + ? args.resumeSessionId + : `droid-sdk-session-${mockState.droidSessionCounter}`; + const initialSettings = (args.settings ?? {}) as Record; + const availableModels = [ + { id: "claude-opus-4-6", displayName: "Claude Opus 4.6" }, + { id: "custom:claude-sonnet-4-6-thinking-32000", displayName: "Custom Claude Sonnet 4.6 Thinking" }, + { id: "custom:Claude-Sonnet-4.6-(High)-1", displayName: "Claude Sonnet 4.6 (High)" }, + ]; const pooled = { - connection: { - newSession: vi.fn(async (params: Record) => { - mockState.droidNewSessionCalls.push(params); - mockState.droidSessionCounter += 1; - return { - sessionId: `droid-acp-session-${mockState.droidSessionCounter}`, - models: { currentModelId: "claude-sonnet-4-5-20250929" }, - configOptions: [], - }; - }), - prompt: vi.fn(async (params: Record) => { - mockState.droidPromptCalls.push(params); - if (mockState.droidPromptGate) await mockState.droidPromptGate; - return { - stopReason: "end_turn", - usage: { inputTokens: 3, outputTokens: 5 }, - }; - }), - cancel: vi.fn(), - unstable_closeSession: vi.fn(), - }, + process: { exitCode: null, killed: false }, bridge: { - onPermission: null, - onSessionUpdate: null, - getRootPath: () => "", - getDirtyFileText: null, - onTerminalOutputDelta: null, - flushTerminalOutput: null, - onTerminalDisposed: null, + onEvent: null, + onPermissionRequest: null, + onAskUserRequest: null, + onReady: null, }, - terminals: new Map(), - terminalWorkLogBindings: new Map(), - terminalOutputTimers: new Map(), + sdkSessionId, + currentModelId: initialSettings.modelId ?? "claude-sonnet-4-5-20250929", + availableModels, + request: vi.fn(async () => null), + sendPrompt: vi.fn(async (payload: Record) => { + mockState.droidPromptCalls.push(payload); + if (mockState.droidPromptGate) await mockState.droidPromptGate; + if (mockState.droidPromptError) throw mockState.droidPromptError; + return { + sessionId: sdkSessionId, + tokenUsage: { inputTokens: 3, outputTokens: 5 }, + success: true, + }; + }), + updateSettings: vi.fn(async (settings: Record): Promise> => { + mockState.droidSettingsUpdates.push(settings); + pooled.currentModelId = settings.modelId ?? pooled.currentModelId; + const ready: Record = { + sessionId: sdkSessionId, + currentModelId: typeof pooled.currentModelId === "string" ? pooled.currentModelId : null, + availableModels, + }; + const onReady = pooled.bridge.onReady as ((ready: Record) => void) | null; + onReady?.(ready); + return ready; + }), + cancel: vi.fn(async () => {}), dispose: vi.fn(), }; mockState.droidPooled = pooled; @@ -576,7 +589,7 @@ vi.mock("./droidAcpPool", () => ({ pooled, }; }), - releaseDroidAcpConnection: vi.fn(), + releaseDroidSdkConnection: vi.fn(), })); // --------------------------------------------------------------------------- @@ -596,7 +609,7 @@ import { runGit } from "../git/git"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import { mapPermissionToClaude, mapPermissionToCodex } from "../orchestrator/permissionMapping"; import { acquireCursorSdkConnection } from "./cursorSdkPool"; -import { acquireDroidAcpConnection } from "./droidAcpPool"; +import { acquireDroidSdkConnection } from "./droidSdkPool"; import type { AgentChatEvent, AgentChatEventEnvelope, ComputerUseBackendStatus, LaneLinearIssue } from "../../../shared/types"; import { makeLinearIssueContextAttachment } from "../../../shared/chatContextAttachments"; import { @@ -1282,12 +1295,14 @@ beforeEach(() => { mockState.droidAcquireCalls = []; mockState.droidNewSessionCalls = []; mockState.droidPromptCalls = []; + mockState.droidSettingsUpdates = []; mockState.droidPooled = null; mockState.droidPromptGate = null; + mockState.droidPromptError = null; vi.mocked(startOpenCodeSession).mockClear(); vi.mocked(buildOpenCodePromptParts).mockClear(); vi.mocked(acquireCursorSdkConnection).mockClear(); - vi.mocked(acquireDroidAcpConnection).mockClear(); + vi.mocked(acquireDroidSdkConnection).mockClear(); vi.mocked(streamText).mockReset(); vi.mocked(claudeSdkCreateSessionCompat).mockReset(); vi.mocked(claudeSdkResumeSessionCompat).mockReset(); @@ -4887,7 +4902,7 @@ describe("createAgentChatService", () => { ); }); - it("adopts Droid ACP session_info_update titles", async () => { + it("adopts Droid SDK session_title_updated titles", async () => { const { service, sessionService } = createService(); const session = await service.createSession({ laneId: "lane-1", @@ -4896,16 +4911,13 @@ describe("createAgentChatService", () => { modelId: "droid/custom:claude-sonnet-4-6-thinking-32000", }); - await service.sendMessage({ sessionId: session.id, text: "Use ACP title." }, { awaitDispatch: true }); + await service.sendMessage({ sessionId: session.id, text: "Use SDK title." }, { awaitDispatch: true }); await vi.waitFor(() => { - expect(typeof mockState.droidPooled.bridge.onSessionUpdate).toBe("function"); + expect(typeof mockState.droidPooled.bridge.onEvent).toBe("function"); }, { timeout: 1_000 }); - mockState.droidPooled.bridge.onSessionUpdate?.({ - sessionId: "droid-acp-session-1", - update: { - sessionUpdate: "session_info_update", - title: "Droid Native Title", - }, + mockState.droidPooled.bridge.onEvent?.({ + type: "session_title_updated", + title: "Droid Native Title", }); await waitForSessionTitle(sessionService, session.id, "Droid Native Title"); @@ -5347,7 +5359,7 @@ describe("createAgentChatService", () => { } }); - it("reports active Droid ACP turns so project switching does not close the chat runtime", async () => { + it("reports active Droid SDK turns so project switching does not close the chat runtime", async () => { const events: AgentChatEventEnvelope[] = []; let finishTurn = () => {}; mockState.droidPromptGate = new Promise((resolve) => { finishTurn = resolve; }); @@ -14254,73 +14266,8 @@ describe("createAgentChatService", () => { }); }); - it("realigns a new Droid ACP session to the selected model before prompting", async () => { + it("configures a new Droid SDK session with the selected model before prompting", async () => { const events: AgentChatEventEnvelope[] = []; - let currentModelId = "claude-opus-4-6"; - const setSessionModel = vi.fn(async ({ modelId }: { modelId: string }) => { - currentModelId = modelId; - }); - const loadSession = vi.fn(async () => ({ - models: { - currentModelId, - availableModels: [ - { modelId: "claude-opus-4-6", name: "Claude Opus 4.6" }, - { modelId: "custom:claude-sonnet-4-6-thinking-32000", name: "Custom Claude Sonnet 4.6 Thinking" }, - ], - }, - configOptions: [], - })); - - vi.mocked(acquireDroidAcpConnection).mockImplementationOnce(async (args: Record) => { - mockState.droidAcquireCalls.push(args); - return { - generation: 1, - pooled: { - connection: { - newSession: vi.fn(async (params: Record) => { - mockState.droidNewSessionCalls.push(params); - mockState.droidSessionCounter += 1; - return { - sessionId: `droid-acp-session-${mockState.droidSessionCounter}`, - models: { - currentModelId, - availableModels: [ - { modelId: "claude-opus-4-6", name: "Claude Opus 4.6" }, - { modelId: "custom:claude-sonnet-4-6-thinking-32000", name: "Custom Claude Sonnet 4.6 Thinking" }, - ], - }, - configOptions: [], - }; - }), - loadSession, - unstable_setSessionModel: setSessionModel, - prompt: vi.fn(async (params: Record) => { - mockState.droidPromptCalls.push(params); - return { - stopReason: "end_turn", - usage: { inputTokens: 2, outputTokens: 4 }, - }; - }), - cancel: vi.fn(), - unstable_closeSession: vi.fn(), - }, - bridge: { - onPermission: null, - onSessionUpdate: null, - getRootPath: () => "", - getDirtyFileText: null, - onTerminalOutputDelta: null, - flushTerminalOutput: null, - onTerminalDisposed: null, - }, - terminals: new Map(), - terminalWorkLogBindings: new Map(), - terminalOutputTimers: new Map(), - dispose: vi.fn(), - }, - } as any; - }); - const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event), }); @@ -14345,94 +14292,34 @@ describe("createAgentChatService", () => { ); const updated = await service.getSessionSummary(session.id); - expect(mockState.droidAcquireCalls[0]?.modelId).toBe("custom:claude-sonnet-4-6-thinking-32000"); - expect(setSessionModel).toHaveBeenCalledWith({ - sessionId: "droid-acp-session-1", + expect(mockState.droidAcquireCalls[0]?.settings).toMatchObject({ modelId: "custom:claude-sonnet-4-6-thinking-32000", }); - // The model switch must complete before the first prompt() call so the - // agent never receives the prompt under the wrong model. - const setModelOrder = setSessionModel.mock.invocationCallOrder[0]; - const firstPromptOrder = ( - mockState.droidPromptCalls.length > 0 - ? (vi.mocked(acquireDroidAcpConnection).mock.results[0]?.value as any)?.pooled?.connection?.prompt?.mock?.invocationCallOrder?.[0] - : undefined - ); - expect(setModelOrder).toBeDefined(); + expect(mockState.droidSettingsUpdates.at(-1)).toMatchObject({ + modelId: "custom:claude-sonnet-4-6-thinking-32000", + interactionMode: "auto", + }); + expect(mockState.droidPromptCalls[0]?.settings).toMatchObject({ + modelId: "custom:claude-sonnet-4-6-thinking-32000", + }); + expect(vi.mocked(buildCodingAgentSystemPrompt)).toHaveBeenCalledWith(expect.objectContaining({ + runtime: "droid-sdk", + mode: "coding", + })); + expect(mockState.droidPromptCalls[0]?.promptText).toContain("system prompt\n\n## User Request"); + const settingsOrder = mockState.droidPooled.updateSettings.mock.invocationCallOrder[0]; + const firstPromptOrder = mockState.droidPooled.sendPrompt.mock.invocationCallOrder[0]; + expect(settingsOrder).toBeDefined(); expect(firstPromptOrder).toBeDefined(); - expect(setModelOrder).toBeLessThan(firstPromptOrder!); + expect(settingsOrder).toBeLessThan(firstPromptOrder!); expect(updated?.model).toBe("custom:claude-sonnet-4-6-thinking-32000"); expect(updated?.modelId).toBe("droid/custom:claude-sonnet-4-6-thinking-32000"); expect(doneEvent.event.model).toBe("custom:claude-sonnet-4-6-thinking-32000"); expect(doneEvent.event.modelId).toBe("droid/custom:claude-sonnet-4-6-thinking-32000"); }); - it("translates Droid custom help ids to ACP session ids before setting the model", async () => { + it("uses Droid spec mode for ADE plan mode", async () => { const events: AgentChatEventEnvelope[] = []; - let currentModelId = "claude-opus-4-6"; - const setSessionModel = vi.fn(async ({ modelId }: { modelId: string }) => { - if (modelId !== "custom:Claude-Sonnet-4.6-(High)-1") { - const error = new Error("Invalid params: Model not recognized") as Error & { - code?: number; - data?: Record; - }; - error.code = -32602; - error.data = { modelId }; - throw error; - } - currentModelId = modelId; - }); - - vi.mocked(acquireDroidAcpConnection).mockImplementationOnce(async (args: Record) => { - mockState.droidAcquireCalls.push(args); - return { - generation: 1, - pooled: { - connection: { - newSession: vi.fn(async (params: Record) => { - mockState.droidNewSessionCalls.push(params); - mockState.droidSessionCounter += 1; - return { - sessionId: `droid-acp-session-${mockState.droidSessionCounter}`, - models: { - currentModelId, - availableModels: [ - { modelId: "claude-opus-4-6", name: "Claude Opus 4.6" }, - { modelId: "custom:Claude-Sonnet-4.6-(High)-1", name: "Claude Sonnet 4.6 (High)" }, - ], - }, - configOptions: [], - }; - }), - loadSession: vi.fn(async () => ({})), - unstable_setSessionModel: setSessionModel, - prompt: vi.fn(async (params: Record) => { - mockState.droidPromptCalls.push(params); - return { - stopReason: "end_turn", - usage: { inputTokens: 1, outputTokens: 2 }, - }; - }), - cancel: vi.fn(), - unstable_closeSession: vi.fn(), - }, - bridge: { - onPermission: null, - onSessionUpdate: null, - getRootPath: () => "", - getDirtyFileText: null, - onTerminalOutputDelta: null, - flushTerminalOutput: null, - onTerminalDisposed: null, - }, - terminals: new Map(), - terminalWorkLogBindings: new Map(), - terminalOutputTimers: new Map(), - dispose: vi.fn(), - }, - } as any; - }); - const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event), }); @@ -14442,112 +14329,32 @@ describe("createAgentChatService", () => { provider: "droid", model: "custom:claude-sonnet-4-6-thinking-32000", modelId: "droid/custom:claude-sonnet-4-6-thinking-32000", + interactionMode: "plan", }); await service.sendMessage({ sessionId: session.id, - text: "Use the high-reasoning custom Sonnet model.", + text: "Draft a Droid spec.", }, { awaitDispatch: true }); - const doneEvent = await waitForEvent( + await waitForEvent( events, - (event): event is AgentChatEventEnvelope & { - event: Extract; - } => event.event.type === "done" && event.sessionId === session.id, + (event): event is AgentChatEventEnvelope => event.event.type === "done" && event.sessionId === session.id, ); - const updated = await service.getSessionSummary(session.id); - expect(setSessionModel).toHaveBeenCalledWith({ - sessionId: "droid-acp-session-1", - modelId: "custom:Claude-Sonnet-4.6-(High)-1", + expect(mockState.droidAcquireCalls[0]?.settings).toMatchObject({ + modelId: "custom:claude-sonnet-4-6-thinking-32000", + interactionMode: "spec", + specModeModelId: "custom:claude-sonnet-4-6-thinking-32000", }); - // The untranslated help id ("custom:claude-sonnet-4-6-thinking-32000") - // must never be sent to ACP — only the translated ACP-recognized id. - const sentModelIds = setSessionModel.mock.calls.map((call) => (call[0] as { modelId: string }).modelId); - expect(sentModelIds).not.toContain("custom:claude-sonnet-4-6-thinking-32000"); - expect(updated?.model).toBe("custom:claude-sonnet-4-6-thinking-32000"); - expect(updated?.modelId).toBe("droid/custom:claude-sonnet-4-6-thinking-32000"); - expect(doneEvent.event.model).toBe("custom:claude-sonnet-4-6-thinking-32000"); - expect(doneEvent.event.modelId).toBe("droid/custom:claude-sonnet-4-6-thinking-32000"); - }); - - it("realigns a resumed Droid ACP session to the selected model during warmup", async () => { - let currentModelId = "claude-opus-4-6"; - const setSessionModel = vi.fn(async ({ modelId }: { modelId: string }) => { - currentModelId = modelId; - }); - const loadSession = vi.fn(async () => ({ - models: { - currentModelId, - availableModels: [ - { modelId: "claude-opus-4-6", name: "Claude Opus 4.6" }, - { modelId: "custom:claude-sonnet-4-6-thinking-32000", name: "Custom Claude Sonnet 4.6 Thinking" }, - ], - }, - configOptions: [], - })); - const resumeSession = vi.fn(async () => ({ - models: { - currentModelId, - availableModels: [ - { modelId: "claude-opus-4-6", name: "Claude Opus 4.6" }, - { modelId: "custom:claude-sonnet-4-6-thinking-32000", name: "Custom Claude Sonnet 4.6 Thinking" }, - ], - }, - configOptions: [], - })); - - vi.mocked(acquireDroidAcpConnection).mockImplementationOnce(async (args: Record) => { - mockState.droidAcquireCalls.push(args); - return { - generation: 1, - pooled: { - connection: { - newSession: vi.fn(async (params: Record) => { - mockState.droidNewSessionCalls.push(params); - mockState.droidSessionCounter += 1; - return { - sessionId: `droid-acp-session-${mockState.droidSessionCounter}`, - models: { - currentModelId, - availableModels: [ - { modelId: "claude-opus-4-6", name: "Claude Opus 4.6" }, - { modelId: "custom:claude-sonnet-4-6-thinking-32000", name: "Custom Claude Sonnet 4.6 Thinking" }, - ], - }, - configOptions: [], - }; - }), - unstable_resumeSession: resumeSession, - loadSession, - unstable_setSessionModel: setSessionModel, - prompt: vi.fn(async (params: Record) => { - mockState.droidPromptCalls.push(params); - return { - stopReason: "end_turn", - usage: { inputTokens: 2, outputTokens: 4 }, - }; - }), - cancel: vi.fn(), - unstable_closeSession: vi.fn(), - }, - bridge: { - onPermission: null, - onSessionUpdate: null, - getRootPath: () => "", - getDirtyFileText: null, - onTerminalOutputDelta: null, - flushTerminalOutput: null, - onTerminalDisposed: null, - }, - terminals: new Map(), - terminalWorkLogBindings: new Map(), - terminalOutputTimers: new Map(), - dispose: vi.fn(), - }, - } as any; + expect(mockState.droidSettingsUpdates.at(-1)).toMatchObject({ + modelId: "custom:claude-sonnet-4-6-thinking-32000", + interactionMode: "spec", + specModeModelId: "custom:claude-sonnet-4-6-thinking-32000", }); + }); + it("resumes a Droid SDK session and applies the selected model during warmup", async () => { const { service } = createService(); const session = await service.createSession({ @@ -14560,7 +14367,7 @@ describe("createAgentChatService", () => { const persisted = readPersistedChatState(session.id); writePersistedChatState(session.id, { ...persisted, - acpSessionId: "persisted-droid-session-1", + droidSdkSessionId: "persisted-droid-session-1", }); await service.warmupModel({ @@ -14570,12 +14377,11 @@ describe("createAgentChatService", () => { const updated = await service.getSessionSummary(session.id); - expect(resumeSession).toHaveBeenCalledWith(expect.objectContaining({ - sessionId: "persisted-droid-session-1", - cwd: fs.realpathSync(tmpRoot), - })); - expect(setSessionModel).toHaveBeenCalledWith({ - sessionId: "persisted-droid-session-1", + expect(mockState.droidAcquireCalls[0]).toMatchObject({ + resumeSessionId: "persisted-droid-session-1", + workspacePath: fs.realpathSync(tmpRoot), + }); + expect(mockState.droidSettingsUpdates.at(-1)).toMatchObject({ modelId: "custom:claude-sonnet-4-6-thinking-32000", }); expect(mockState.droidNewSessionCalls).toHaveLength(0); @@ -14583,57 +14389,13 @@ describe("createAgentChatService", () => { expect(updated?.modelId).toBe("droid/custom:claude-sonnet-4-6-thinking-32000"); }); - it("surfaces structured Droid ACP failures without collapsing them to [object Object]", async () => { + it("surfaces structured Droid SDK failures without collapsing them to [object Object]", async () => { const events: AgentChatEventEnvelope[] = []; - - vi.mocked(acquireDroidAcpConnection).mockImplementationOnce(async (args: Record) => { - mockState.droidAcquireCalls.push(args); - return { - generation: 1, - pooled: { - connection: { - newSession: vi.fn(async (params: Record) => { - mockState.droidNewSessionCalls.push(params); - mockState.droidSessionCounter += 1; - return { - sessionId: `droid-acp-session-${mockState.droidSessionCounter}`, - models: { - currentModelId: "custom:Claude-Sonnet-4.6-(High)-1", - availableModels: [ - { modelId: "custom:Claude-Sonnet-4.6-(High)-1", name: "Claude Sonnet 4.6 (High)" }, - ], - }, - configOptions: [], - }; - }), - loadSession: vi.fn(async () => ({})), - unstable_setSessionModel: vi.fn(async () => {}), - prompt: vi.fn(async () => { - throw { - code: -32603, - message: "Connection error.", - data: "This might be a network issue. Please check your internet connection.", - }; - }), - cancel: vi.fn(), - unstable_closeSession: vi.fn(), - }, - bridge: { - onPermission: null, - onSessionUpdate: null, - getRootPath: () => "", - getDirtyFileText: null, - onTerminalOutputDelta: null, - flushTerminalOutput: null, - onTerminalDisposed: null, - }, - terminals: new Map(), - terminalWorkLogBindings: new Map(), - terminalOutputTimers: new Map(), - dispose: vi.fn(), - }, - } as any; - }); + mockState.droidPromptError = { + code: -32603, + message: "Connection error.", + data: "This might be a network issue. Please check your internet connection.", + }; const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event), @@ -14660,10 +14422,9 @@ describe("createAgentChatService", () => { expect(errorEvent.event.message).toBe("Connection error."); expect(errorEvent.event.detail).toContain("network issue"); - expect(errorEvent.event.errorInfo).toEqual({ + expect(errorEvent.event.errorInfo).toMatchObject({ category: "network", provider: "Factory Droid", - model: "Claude Sonnet 4.6 (High)", }); }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 5e2421157..1de3ed8ae 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -247,14 +247,8 @@ import { import { peekOpenCodeInventoryCache, probeOpenCodeProviderInventory } from "../opencode/openCodeInventory"; import { inspectLocalProvider } from "../ai/localModelDiscovery"; import type { - ClientSideConnection, - CloseSessionRequest, - CloseSessionResponse, PermissionOption, - RequestPermissionRequest, RequestPermissionResponse, - ResumeSessionRequest, - ResumeSessionResponse, } from "@agentclientprotocol/sdk"; import { resolveDroidExecutable } from "../ai/droidExecutable"; import { @@ -265,17 +259,22 @@ import { type CursorSdkPooled, } from "./cursorSdkPool"; import { - acquireDroidAcpConnection, - releaseDroidAcpConnection, - type DroidAcpLaunchSettings, - type DroidAcpPooled, -} from "./droidAcpPool"; + acquireDroidSdkConnection, + releaseDroidSdkConnection, + type DroidSdkPooled, +} from "./droidSdkPool"; import { discoverCursorSdkModelDescriptors } from "./cursorModelsDiscovery"; -import { discoverDroidCliModelDescriptors } from "./droidModelsDiscovery"; +import { discoverDroidSdkModelDescriptors } from "./droidModelsDiscovery"; import { mapCursorSdkMessageToChatEvents, mapCursorSdkRunResultToDoneEvent, } from "./cursorSdkEventMapper"; +import { + createDroidSdkEventMapperState, + mapDroidSdkMessageToChatEvents, + mapDroidSdkRunResultToDoneEvent, + type DroidSdkEventMapperState, +} from "./droidSdkEventMapper"; import { allowCursorHook, approvalPolicyLabel, @@ -293,6 +292,14 @@ import type { CursorSdkHookRequest, CursorSdkPermissionPolicy, } from "./cursorSdkProtocol"; +import type { + DroidSdkAskUserRequest, + DroidSdkAskUserResponse, + DroidSdkPermissionDecision, + DroidSdkPermissionRequest, + DroidSdkReasoningEffort, + DroidSdkSessionSettings, +} from "./droidSdkProtocol"; import { buildCursorSdkSystemPrompt, CURSOR_SDK_PROMPT_INJECT_ENV, @@ -303,12 +310,7 @@ import { type CursorSdkRuntime, } from "./cursorSdkSystemPrompt"; import { promises as fsPromises } from "node:fs"; -import { - mapAcpSessionNotificationToChatEvents, - mapStopReasonToTerminalEvents, - parseAcpTerminalIdFromCommandItemId, -} from "./acpEventMapper"; -import { readAcpConfigSnapshot } from "./acpConfigState"; +import { mapStopReasonToTerminalEvents } from "./acpEventMapper"; import { CURSOR_AVAILABLE_MODE_IDS } from "../../../shared/cursorModes"; import { getApiKey } from "../ai/apiKeyStore"; import type { createMissionService } from "../missions/missionService"; @@ -373,8 +375,10 @@ type PersistedChatState = { capabilityMode?: CtoCapabilityMode; completion?: AgentChatCompletionReport | null; threadId?: string; - /** ACP session id for Droid resume across app restarts (best-effort). */ + /** Legacy ACP session id for Droid resume across app restarts (best-effort). */ acpSessionId?: string; + /** Factory Droid SDK session id for Droid resume across app restarts (best-effort). */ + droidSdkSessionId?: string; sdkSessionId?: string; forkFromSdkSessionId?: string; providerSessionId?: string; @@ -727,20 +731,19 @@ type DroidRuntime = { kind: "droid"; poolKey: string; poolGeneration: number; - pooled: DroidAcpPooled | null; - acpSessionId: string | null; + sdk: DroidSdkPooled; + sdkSessionId: string | null; activeTurnId: string | null; busy: boolean; interrupted: boolean; /** The model ADE intends this session to use. */ modelId: string; - /** The model ACP reports the live session is currently using. */ + /** The model the Factory SDK reports the live session is currently using. */ currentModelId: string | null; availableModelIds: string[]; - acpModelIdByDisplayKey: Map; - displayKeyByAcpModelId: Map; pendingSteers: QueuedSteer[]; - permissionWaiters: Map; + permissionWaiters: Map; + eventMapperState: DroidSdkEventMapperState; }; type ChatRuntime = CodexRuntime | ClaudeRuntime | OpenCodeRuntime | CursorRuntime | DroidRuntime; @@ -753,6 +756,16 @@ function cancelCursorPermissionWaiter(waiter: CursorPermissionWaiter, reason: st waiter.resolve({ outcome: { outcome: "cancelled" } }); } +type DroidPermissionWaiter = { + toolName: string; + request: DroidSdkPermissionRequest; + resolve: (value: DroidSdkPermissionDecision) => void; +}; + +function cancelDroidPermissionWaiter(waiter: DroidPermissionWaiter, reason: string): void { + waiter.resolve({ selectedOption: "cancel", comment: reason }); +} + function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record @@ -3501,6 +3514,7 @@ function syncLegacyPermissionMode(session: Pick< } if (session.provider === "droid") { + if (session.interactionMode === "plan") return "plan"; return droidPermissionModeToLegacyPermissionMode( session.droidPermissionMode ?? legacyOpenCodePermissionModeToDroidPermissionMode(session.opencodePermissionMode), @@ -3541,6 +3555,7 @@ function applyLegacyPermissionModeToNativeControls( } if (session.provider === "droid") { + session.interactionMode = mode === "plan" ? "plan" : "default"; session.droidPermissionMode = legacyPermissionModeToDroidPermissionMode(mode); return; } @@ -3592,6 +3607,9 @@ function hydrateNativePermissionControls( session.codexSandbox = session.codexSandbox ?? legacyPermissionModeToCodexSandbox(session.permissionMode); session.codexConfigSource = session.codexConfigSource ?? legacyPermissionModeToCodexConfigSource(session.permissionMode); } else if (session.provider === "droid") { + session.interactionMode = session.interactionMode === "plan" || session.permissionMode === "plan" + ? "plan" + : "default"; session.droidPermissionMode = session.droidPermissionMode ?? legacyPermissionModeToDroidPermissionMode(session.permissionMode) ?? legacyOpenCodePermissionModeToDroidPermissionMode(session.opencodePermissionMode); @@ -3843,56 +3861,6 @@ function getCursorSdkApiKey(): string | null { return env || null; } -const ACP_SERVER_LIST_KEY = ["m", "cpServers"].join(""); - -function acpSessionRequest>(request: T): T { - return { - ...request, - [ACP_SERVER_LIST_KEY]: [], - } as T; -} - -type AcpSessionLifecycleConnection = ClientSideConnection & { - closeSession?: (params: CloseSessionRequest) => Promise; - unstable_closeSession?: (params: CloseSessionRequest) => Promise; - resumeSession?: (params: ResumeSessionRequest) => Promise; - unstable_resumeSession?: (params: ResumeSessionRequest) => Promise; -}; - -function acpSessionLifecycle(connection: ClientSideConnection): AcpSessionLifecycleConnection { - return connection as AcpSessionLifecycleConnection; -} - -async function closeAcpSession( - connection: ClientSideConnection | null | undefined, - sessionId: string | null | undefined, -): Promise { - const normalizedSessionId = sessionId?.trim(); - if (!connection || !normalizedSessionId) return; - const lifecycle = acpSessionLifecycle(connection); - if (typeof lifecycle.closeSession === "function") { - await lifecycle.closeSession({ sessionId: normalizedSessionId }); - return; - } - if (typeof lifecycle.unstable_closeSession === "function") { - await lifecycle.unstable_closeSession({ sessionId: normalizedSessionId }); - } -} - -async function resumeAcpSession( - connection: ClientSideConnection, - request: ResumeSessionRequest, -): Promise { - const lifecycle = acpSessionLifecycle(connection); - if (typeof lifecycle.resumeSession === "function") { - return lifecycle.resumeSession(request); - } - if (typeof lifecycle.unstable_resumeSession === "function") { - return lifecycle.unstable_resumeSession(request); - } - return null; -} - function normalizeCursorConfigValueRecord( value: unknown, ): Record | undefined { @@ -3992,21 +3960,47 @@ function resolveDroidRuntimeModelId( return DEFAULT_DROID_MODEL; } -function resolveDroidAcpLaunchSettings( +function resolveDroidSdkAutonomyLevel( session: Pick, -): DroidAcpLaunchSettings { +): DroidSdkSessionSettings["autonomyLevel"] { const mode = resolveSessionDroidPermissionMode(session, "auto-low"); switch (mode) { case "read-only": - return { autonomy: "none" }; + return "off"; case "auto-low": - return { autonomy: "low" }; + return "low"; case "auto-medium": - return { autonomy: "medium" }; + return "medium"; case "auto-high": - return { autonomy: "high" }; + return "high"; + default: + return "low"; + } +} + +function resolveDroidSdkInteractionMode( + session: Pick, +): DroidSdkSessionSettings["interactionMode"] { + return session.interactionMode === "plan" || session.permissionMode === "plan" ? "spec" : "auto"; +} + +function normalizeDroidSdkReasoningEffort(value: string | null | undefined): DroidSdkReasoningEffort | null { + const normalized = value?.trim().toLowerCase(); + switch (normalized) { + case "none": + case "dynamic": + case "off": + case "minimal": + case "low": + case "medium": + case "high": + case "xhigh": + return normalized; + case "extra-high": + case "extra_high": + return "xhigh"; default: - return { autonomy: "low" }; + return null; } } @@ -4026,18 +4020,6 @@ function normalizeDroidReportedModelId( return /^[\w.:()+-]+$/i.test(trimmed) ? trimmed : null; } -function normalizeDroidDisplayKey(value: string | null | undefined): string | null { - const normalized = String(value ?? "").replace(/\s+/g, " ").trim().toLowerCase(); - return normalized.length ? normalized : null; -} - -function resolveDroidDisplayKeyForModelId(modelId: string | null | undefined): string | null { - const trimmed = String(modelId ?? "").trim(); - if (!trimmed.length) return null; - const descriptor = getModelById(`droid/${trimmed}`) ?? resolveModelDescriptorForProvider(trimmed, "droid"); - return normalizeDroidDisplayKey(descriptor?.displayName ?? trimmed); -} - function normalizeSessionNativePermissionControls( session: Pick< AgentChatSession, @@ -4067,7 +4049,9 @@ function normalizeSessionNativePermissionControls( delete session.opencodePermissionMode; delete session.droidPermissionMode; } else if (session.provider === "droid") { - delete session.interactionMode; + session.interactionMode = session.interactionMode === "plan" || session.permissionMode === "plan" + ? "plan" + : "default"; session.droidPermissionMode = resolveSessionDroidPermissionMode(session, "auto-low"); delete session.claudePermissionMode; delete session.codexApprovalPolicy; @@ -4377,111 +4361,7 @@ export function createAgentChatService(args: { }; const managedSessions = new Map(); - const acpHostSessionOwners = new Map(); - const acpHostBridgeWired = new WeakSet(); - /** - * Dedup guard for Droid ACP session notifications. - * - * The droid exec binary has two duplicate-emission behaviors: - * 1. Duplicate `current_mode_update` notifications per turn. - * 2. After streaming `agent_message_chunk` deltas for the current turn, it - * sends a final chunk containing the concatenation of ALL previous turns' - * assistant text (conversation history replay). - * - * Per ACP session we track: - * - `historyText`: accumulated text from all completed turns (used to detect - * the history-replay chunk in subsequent turns) - * - `currentTurnText`: text streamed so far in the active turn - * - `seenModes`: mode IDs already emitted in the active turn - */ - const droidSessionDedup = new Map; - }>(); - - function isDuplicateDroidNotification( - sessionId: string, - turnId: string, - note: { update: Record }, - ): boolean { - const u = note.update; - - if (u.sessionUpdate === "agent_message_chunk") { - const c = u.content as { type?: string; text?: string } | undefined; - const chunkText = c?.text ?? ""; - if (!chunkText.length) return false; - - let entry = droidSessionDedup.get(sessionId); - if (!entry) { - entry = { historyText: "", currentTurnText: "", currentTurnId: turnId, seenModes: new Set() }; - droidSessionDedup.set(sessionId, entry); - } - - // New turn — commit previous turn's text to history and reset. - if (entry.currentTurnId !== turnId) { - entry.historyText += entry.currentTurnText; - entry.currentTurnText = ""; - entry.currentTurnId = turnId; - entry.seenModes.clear(); - } - - // Replay chunks are full multi-line agent_message_chunks containing text - // from previous turns. Restrict the substring check to chunks long enough - // that an accidental match against a tiny streaming delta (e.g. " yes") - // is implausible. - const REPLAY_MIN_LEN = 32; - if ( - chunkText.length >= REPLAY_MIN_LEN - && entry.historyText.length > 0 - && entry.historyText.includes(chunkText) - ) { - return true; - } - - // Also catch the case where this chunk replays the current turn's - // own streamed text (the original dedup scenario). - if ( - chunkText.length >= REPLAY_MIN_LEN - && entry.currentTurnText.length > 0 - && entry.currentTurnText.includes(chunkText) - ) { - return true; - } - - // Genuine streaming delta — accumulate. - entry.currentTurnText += chunkText; - return false; - } - - if (u.sessionUpdate === "current_mode_update") { - const modeId = String(u.currentModeId ?? ""); - let entry = droidSessionDedup.get(sessionId); - if (!entry) { - entry = { historyText: "", currentTurnText: "", currentTurnId: turnId, seenModes: new Set() }; - droidSessionDedup.set(sessionId, entry); - } - if (entry.currentTurnId !== turnId) { - entry.historyText += entry.currentTurnText; - entry.currentTurnText = ""; - entry.currentTurnId = turnId; - entry.seenModes.clear(); - } - if (entry.seenModes.has(modeId)) { - return true; - } - entry.seenModes.add(modeId); - return false; - } - - return false; - } - - function clearDroidSessionDedup(sessionId: string): void { - droidSessionDedup.delete(sessionId); - } - /** Interrupt arrived while `ensureDroidRuntime` was still acquiring the pooled CLI. */ + /** Interrupt arrived while `ensureDroidRuntime` was still acquiring the SDK worker. */ const droidRuntimeSetupInterruptRequested = new WeakMap(); const sessionTurnCollectors = new Map(); const subagentStates = new Map>(); @@ -7124,8 +7004,8 @@ export function createAgentChatService(args: { ...(managed.session.completion ? { completion: managed.session.completion } : {}), ...(managed.session.threadId ? { threadId: managed.session.threadId } : {}), ...(managed.session.runtimeMode ? { runtimeMode: managed.session.runtimeMode } : {}), - ...(managed.runtime?.kind === "droid" && managed.runtime.acpSessionId - ? { acpSessionId: managed.runtime.acpSessionId } + ...(managed.runtime?.kind === "droid" && managed.runtime.sdkSessionId + ? { droidSdkSessionId: managed.runtime.sdkSessionId } : {}), ...(managed.session.provider === "claude" && claudePersistedSdkSessionId ? { sdkSessionId: claudePersistedSdkSessionId } @@ -7299,6 +7179,11 @@ export function createAgentChatService(args: { const sdkSessionId = typeof record.sdkSessionId === "string" && record.sdkSessionId.trim().length ? record.sdkSessionId.trim() : claudePointer?.sessionId; + const droidSdkSessionId = provider === "droid" && typeof record.droidSdkSessionId === "string" && record.droidSdkSessionId.trim().length + ? record.droidSdkSessionId.trim() + : provider === "droid" && typeof record.acpSessionId === "string" && record.acpSessionId.trim().length + ? record.acpSessionId.trim() + : undefined; const forkFromSdkSessionId = typeof record.forkFromSdkSessionId === "string" && record.forkFromSdkSessionId.trim().length ? record.forkFromSdkSessionId.trim() : undefined; @@ -7396,6 +7281,7 @@ export function createAgentChatService(args: { ...(typeof record.acpSessionId === "string" && record.acpSessionId.trim().length ? { acpSessionId: record.acpSessionId.trim() } : {}), + ...(droidSdkSessionId ? { droidSdkSessionId } : {}), ...(sdkSessionId ? { sdkSessionId } : {}), ...(forkFromSdkSessionId ? { forkFromSdkSessionId } : {}), ...(providerSessionId ? { providerSessionId } : {}), @@ -8210,16 +8096,11 @@ export function createAgentChatService(args: { } if (managed.runtime?.kind === "droid") { const rt = managed.runtime; - if (rt.acpSessionId) { - acpHostSessionOwners.delete(rt.acpSessionId); - clearDroidSessionDedup(rt.acpSessionId); - void closeAcpSession(rt.pooled?.connection, rt.acpSessionId).catch(() => {}); - } for (const [, w] of rt.permissionWaiters) { - cancelCursorPermissionWaiter(w, "Tool approval was cancelled because the session closed."); + cancelDroidPermissionWaiter(w, "Droid tool approval was cancelled because the session closed."); } rt.permissionWaiters.clear(); - if (rt.pooled) releaseDroidAcpConnection(rt.poolKey, rt.poolGeneration); + releaseDroidSdkConnection(rt.poolKey, rt.poolGeneration); managed.runtime = null; } managed.runtimeInvalidated = !preserveClaudeResumeState; @@ -14432,9 +14313,9 @@ export function createAgentChatService(args: { await runDroidTurn(managed, { promptText, displayText: trimmed, - attachments: [], + attachments: nextSteer.attachments, contextAttachments: nextSteer.contextAttachments, - resolvedAttachments: [], + resolvedAttachments: nextSteer.resolvedAttachments, laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null, }); } else { @@ -15103,6 +14984,9 @@ export function createAgentChatService(args: { } if (effectiveProvider === "droid") { return { + interactionMode: effectiveInteractionMode === "plan" || effectivePermissionMode === "plan" + ? "plan" as const + : "default" as const, droidPermissionMode: requestedDroidPermissionMode ?? legacyPermissionModeToDroidPermissionMode(effectivePermissionMode) ?? legacyOpenCodePermissionModeToDroidPermissionMode(requestedOpenCodePermissionMode) @@ -15594,15 +15478,7 @@ export function createAgentChatService(args: { const { managed } = prepared; if (managed.closed) return; - const descriptor = resolveSessionModelDescriptor(managed.session); - const acpError = managed.session.provider === "droid" - ? classifyAcpHostError( - error, - "Factory Droid", - descriptor?.displayName ?? managed.session.model, - ) - : null; - const message = acpError?.message ?? (error instanceof Error ? error.message : String(error)); + const message = error instanceof Error ? error.message : String(error); const turnId = prepared.turnId ?? randomUUID(); // If the failure is "turn already active", the original turn is still running. @@ -15644,8 +15520,6 @@ export function createAgentChatService(args: { emitChatEvent(managed, { type: "error", message, - ...(acpError?.detail ? { detail: acpError.detail } : {}), - ...(acpError?.errorInfo ? { errorInfo: acpError.errorInfo } : {}), turnId, }); emitChatEvent(managed, { @@ -15806,35 +15680,6 @@ export function createAgentChatService(args: { } }; - const buildAcpHostPendingInputRequest = ( - itemId: string, - req: RequestPermissionRequest, - source: "cursor" | "droid", - turnId?: string | null, - ): PendingInputRequest => ({ - requestId: itemId, - itemId, - source, - kind: "permissions", - title: req.toolCall.title ?? (source === "droid" ? "Droid permission required" : "Cursor permission required"), - description: req.toolCall.title - ?? (source === "droid" ? "Droid needs approval before continuing." : "Cursor needs approval before continuing."), - questions: [], - allowsFreeform: false, - blocking: true, - canProceedWithoutAnswer: false, - options: req.options.map((option) => ({ - label: cursorPermissionOptionLabel(option.kind), - value: option.optionId, - ...(option.kind === "allow_always" ? { recommended: true } : {}), - })), - providerMetadata: { - toolCall: req.toolCall, - options: req.options, - }, - turnId: turnId ?? null, - }); - const buildCursorSdkPendingInputRequest = ( itemId: string, req: CursorSdkHookRequest, @@ -16327,145 +16172,47 @@ export function createAgentChatService(args: { } }; - const updateDroidAcpModelLookups = ( - runtime: DroidRuntime, - entries: Array<{ modelId?: string | null; name?: string | null } | null> | null | undefined, - ): void => { - for (const entry of entries ?? []) { - const rawModelId = String(entry?.modelId ?? "").trim(); - if (!rawModelId.length) continue; - const displayKey = normalizeDroidDisplayKey(entry?.name) - ?? resolveDroidDisplayKeyForModelId(rawModelId); - if (!displayKey) continue; - runtime.acpModelIdByDisplayKey.set(displayKey, rawModelId); - runtime.displayKeyByAcpModelId.set(rawModelId, displayKey); - } - }; - - const resolveDroidAcpModelId = ( - runtime: DroidRuntime, - canonicalModelId: string, - ): string => { - const trimmed = canonicalModelId.trim(); - if (!trimmed.length) return trimmed; - const displayKey = resolveDroidDisplayKeyForModelId(trimmed); - if (displayKey) { - return runtime.acpModelIdByDisplayKey.get(displayKey) ?? trimmed; - } - return trimmed; - }; - - const resolveCanonicalDroidModelId = ( + const buildDroidSdkSessionSettings = ( managed: ManagedChatSession, - runtime: DroidRuntime, - acpModelId: string | null | undefined, - ): string | null => { - const trimmed = String(acpModelId ?? "").trim(); - if (!trimmed.length) return null; - - const direct = getModelById(`droid/${trimmed}`) ?? resolveModelDescriptorForProvider(trimmed, "droid"); - if (direct?.family === "factory") { - const selectedCanonicalModelId = runtime.modelId.trim() || resolveDroidRuntimeModelId(managed.session); - const selectedDisplayKey = resolveDroidDisplayKeyForModelId(selectedCanonicalModelId); - const currentDisplayKey = runtime.displayKeyByAcpModelId.get(trimmed) - ?? resolveDroidDisplayKeyForModelId(trimmed); - if (selectedDisplayKey && currentDisplayKey && selectedDisplayKey === currentDisplayKey) { - return selectedCanonicalModelId; - } - return direct.providerModelId; - } - - return /^[\w.:()+-]+$/i.test(trimmed) ? trimmed : null; - }; - - const applyDroidModelSnapshot = ( - _managed: ManagedChatSession, - runtime: DroidRuntime, - payload: { - models?: { - currentModelId?: string | null; - availableModels?: Array<{ modelId?: string | null; name?: string | null } | null> | null; - } | null; - configOptions?: Parameters[0]; - } | null | undefined, - ): { - currentModelId: string | null; - modelConfigId: string | null; - } => { - const configSnapshot = readAcpConfigSnapshot(payload?.configOptions); - updateDroidAcpModelLookups(runtime, payload?.models?.availableModels); - for (const modelId of configSnapshot.availableModelIds) { - const rawModelId = String(modelId ?? "").trim(); - if (!rawModelId.length) continue; - const displayKey = resolveDroidDisplayKeyForModelId(rawModelId); - if (!displayKey) continue; - runtime.acpModelIdByDisplayKey.set(displayKey, rawModelId); - runtime.displayKeyByAcpModelId.set(rawModelId, displayKey); - } - const reportedAvailableModelIds = payload?.models?.availableModels - ?.map((entry) => normalizeDroidReportedModelId(entry?.modelId ?? null)) - .filter((entry): entry is string => Boolean(entry)) ?? []; - const availableModelIds = Array.from(new Set([ - ...runtime.availableModelIds, - ...reportedAvailableModelIds, - ...configSnapshot.availableModelIds - .map((entry) => normalizeDroidReportedModelId(entry)) - .filter((entry): entry is string => Boolean(entry)), - ])); - runtime.availableModelIds = availableModelIds; - const currentModelId = normalizeDroidReportedModelId( - payload?.models?.currentModelId ?? configSnapshot.currentModelId, - availableModelIds, - ); - if (currentModelId) { - runtime.currentModelId = currentModelId; - } + modelId: string, + ): DroidSdkSessionSettings => { + const reasoningEffort = normalizeDroidSdkReasoningEffort(managed.session.reasoningEffort); + const interactionMode = resolveDroidSdkInteractionMode(managed.session); return { - currentModelId, - modelConfigId: configSnapshot.modelConfigId, + modelId, + autonomyLevel: resolveDroidSdkAutonomyLevel(managed.session), + interactionMode, + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(interactionMode === "spec" + ? { + specModeModelId: modelId, + ...(reasoningEffort ? { specModeReasoningEffort: reasoningEffort } : {}), + } + : {}), }; }; - const refreshDroidSessionState = async ( + const applyDroidSdkReadyState = ( managed: ManagedChatSession, runtime: DroidRuntime, - reason: "after_prompt" | "manual_sync" | "session_update" | "ensure_before_sync" | "set_model_failed", - ): Promise<{ - currentModelId: string | null; - modelConfigId: string | null; - }> => { - const sessionId = runtime.acpSessionId?.trim(); - if (!sessionId || !runtime.pooled) { - return { currentModelId: runtime.currentModelId, modelConfigId: null }; - } - - const loadSession = runtime.pooled.connection.loadSession?.bind(runtime.pooled.connection); - if (!loadSession) { - return { currentModelId: runtime.currentModelId, modelConfigId: null }; + ready: { + sessionId?: string | null; + currentModelId?: string | null; + availableModels?: Array<{ id?: string | null; modelId?: string | null } | null>; + } | null | undefined, + ): void => { + const sdkSessionId = ready?.sessionId?.trim(); + if (sdkSessionId) runtime.sdkSessionId = sdkSessionId; + const availableModelIds = ready?.availableModels + ?.map((entry) => normalizeDroidReportedModelId(entry?.id ?? entry?.modelId ?? null)) + .filter((entry): entry is string => Boolean(entry)) ?? []; + if (availableModelIds.length) { + runtime.availableModelIds = Array.from(new Set([...runtime.availableModelIds, ...availableModelIds])); } - - try { - const loaded = await loadSession(acpSessionRequest({ - sessionId, - cwd: managed.laneWorktreePath, - }) as Parameters[0]); - const snapshot = applyDroidModelSnapshot(managed, runtime, loaded); - if ((reason === "after_prompt" || reason === "set_model_failed") && snapshot.currentModelId) { - const canonicalModelId = resolveCanonicalDroidModelId(managed, runtime, snapshot.currentModelId); - if (canonicalModelId) { - syncDroidSessionDescriptor(managed, canonicalModelId, { runtime }); - runtime.currentModelId = snapshot.currentModelId; - } - } - return snapshot; - } catch (error) { - logger.warn("agent_chat.droid_load_session_failed", { - sessionId: managed.session.id, - acpSessionId: sessionId, - reason, - error: error instanceof Error ? error.message : String(error), - }); - return { currentModelId: runtime.currentModelId, modelConfigId: null }; + const currentModelId = normalizeDroidReportedModelId(ready?.currentModelId ?? null, runtime.availableModelIds); + if (currentModelId) { + runtime.currentModelId = currentModelId; + syncDroidSessionDescriptor(managed, currentModelId, { runtime, updateCurrent: true }); } }; @@ -16473,89 +16220,14 @@ export function createAgentChatService(args: { managed: ManagedChatSession, runtime: DroidRuntime, ): Promise => { - const sessionId = runtime.acpSessionId?.trim(); - if (!sessionId || !runtime.pooled) return; - - if (!runtime.currentModelId) { - await refreshDroidSessionState(managed, runtime, "ensure_before_sync"); - } - const desiredModelId = runtime.modelId.trim() || resolveDroidRuntimeModelId(managed.session); - const desiredAcpModelId = resolveDroidAcpModelId(runtime, desiredModelId); - if (!desiredModelId.length || !desiredAcpModelId.length) return; - - if (runtime.currentModelId === desiredAcpModelId) { - syncDroidSessionDescriptor(managed, desiredModelId, { runtime }); - runtime.currentModelId = desiredAcpModelId; - return; - } - - let modelUpdated = false; - const loadSnapshot = await refreshDroidSessionState(managed, runtime, "manual_sync"); - if (loadSnapshot.currentModelId === desiredAcpModelId) { - syncDroidSessionDescriptor(managed, desiredModelId, { runtime }); - runtime.currentModelId = desiredAcpModelId; - return; - } - - if ( - loadSnapshot.modelConfigId - && runtime.availableModelIds.includes(desiredAcpModelId) - && typeof runtime.pooled.connection.setSessionConfigOption === "function" - ) { - try { - const response = await runtime.pooled.connection.setSessionConfigOption({ - sessionId, - configId: loadSnapshot.modelConfigId, - value: desiredAcpModelId, - }); - const applied = applyDroidModelSnapshot(managed, runtime, response); - if (!applied.currentModelId) { - runtime.currentModelId = desiredAcpModelId; - } - syncDroidSessionDescriptor(managed, desiredModelId, { runtime }); - modelUpdated = true; - } catch (error) { - logger.warn("agent_chat.droid_set_session_model_config_failed", { - sessionId: managed.session.id, - acpSessionId: sessionId, - desiredModelId, - configId: loadSnapshot.modelConfigId, - currentModelId: runtime.currentModelId, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - if (!modelUpdated && typeof runtime.pooled.connection.unstable_setSessionModel === "function") { - try { - await runtime.pooled.connection.unstable_setSessionModel({ - sessionId, - modelId: desiredAcpModelId, - }); - syncDroidSessionDescriptor(managed, desiredModelId, { runtime }); - runtime.currentModelId = desiredAcpModelId; - modelUpdated = true; - } catch (error) { - logger.warn("agent_chat.droid_set_session_model_failed", { - sessionId: managed.session.id, - acpSessionId: sessionId, - desiredModelId, - currentModelId: runtime.currentModelId, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - if (!modelUpdated) { - const refreshed = await refreshDroidSessionState(managed, runtime, "set_model_failed"); - if (refreshed.currentModelId) { - const canonicalModelId = resolveCanonicalDroidModelId(managed, runtime, refreshed.currentModelId); - if (canonicalModelId) { - syncDroidSessionDescriptor(managed, canonicalModelId, { runtime }); - runtime.currentModelId = refreshed.currentModelId; - } - } + if (!desiredModelId.length) return; + const ready = await runtime.sdk.updateSettings(buildDroidSdkSessionSettings(managed, desiredModelId)); + runtime.modelId = desiredModelId; + syncDroidSessionDescriptor(managed, desiredModelId, { runtime }); + applyDroidSdkReadyState(managed, runtime, ready); + if (!runtime.currentModelId) { + runtime.currentModelId = desiredModelId; } }; @@ -16624,168 +16296,140 @@ export function createAgentChatService(args: { return blocks; }; - const emitAcpHostTerminalCommandIfBound = ( - pooled: DroidAcpPooled, - acpSessionId: string, - terminalId: string, - ): void => { - const owner = acpHostSessionOwners.get(acpSessionId); - if (!owner?.runtime || owner.runtime.kind !== "droid") return; - const binding = pooled.terminalWorkLogBindings.get(terminalId); - if (!binding) return; - const t = pooled.terminals.get(terminalId); - if (!t) return; - const output = t.truncated ? `${t.output}\n…(output truncated)` : t.output; - const cmdStatus = t.exited ? (t.exitCode === 0 ? "completed" : "failed") : "running"; - emitChatEvent(owner, { - type: "command", - command: binding.command, - cwd: binding.cwd, - output, - itemId: binding.itemId, - turnId: binding.turnId, - status: cmdStatus, - ...(t.exited ? { exitCode: t.exitCode } : {}), - }); - }; - - const scheduleAcpHostTerminalEmit = ( - pooled: DroidAcpPooled, - terminalId: string, - acpSessionId: string, - ): void => { - const existing = pooled.terminalOutputTimers.get(terminalId); - if (existing) clearTimeout(existing); - const DEBOUNCE_MS = 80; - pooled.terminalOutputTimers.set( - terminalId, - setTimeout(() => { - pooled.terminalOutputTimers.delete(terminalId); - emitAcpHostTerminalCommandIfBound(pooled, acpSessionId, terminalId); - }, DEBOUNCE_MS), - ); + const mapChatDecisionToDroidPermission = ( + decision: AgentChatApprovalDecision | undefined, + request: DroidSdkPermissionRequest, + answers?: Record, + ): DroidSdkPermissionDecision => { + if (answers) { + const explicit = Object.values(answers).flat()[0]; + if (typeof explicit === "string" && request.options.some((option) => option.value === explicit)) { + return { selectedOption: explicit }; + } + } + const options = request.options.map((option) => option.value); + const pick = (...candidates: string[]) => candidates.find((candidate) => options.includes(candidate)); + if (decision === "accept_for_session") { + return { selectedOption: pick("proceed_always", "proceed_auto_run", "proceed_once") ?? "proceed_always" }; + } + if (decision === "accept") { + return { selectedOption: pick("proceed_once", "proceed_always", "proceed_auto_run") ?? "proceed_once" }; + } + return { selectedOption: "cancel" }; }; - const wireAcpHostBridgeHandlers = (pooled: DroidAcpPooled): void => { - if (acpHostBridgeWired.has(pooled)) return; - acpHostBridgeWired.add(pooled); - pooled.bridge.onSessionUpdate = (note) => { - const owner = acpHostSessionOwners.get(note.sessionId); - if (!owner?.runtime) return; - const rt = owner.runtime; - if (rt.kind !== "droid") return; - - // Droid exec sends streaming chunks + a final complete-text replay, and - // duplicate current_mode_update notifications. Suppress the duplicates. - if (rt.kind === "droid" && isDuplicateDroidNotification(note.sessionId, rt.activeTurnId ?? "", note as { update: Record })) { - return; - } + const buildDroidSdkPendingInputRequest = ( + itemId: string, + req: DroidSdkPermissionRequest, + turnId?: string | null, + ): PendingInputRequest => ({ + requestId: itemId, + itemId, + source: "droid", + kind: "permissions", + title: "Droid permission required", + description: req.summary || req.title || "Droid wants to use a tool.", + questions: [], + allowsFreeform: false, + blocking: true, + canProceedWithoutAnswer: false, + options: req.options.map((option) => ({ + label: option.label, + value: option.value, + ...(option.value === "proceed_always" || option.value === "proceed_auto_run" ? { recommended: true } : {}), + })), + providerMetadata: { + droidSdk: true, + toolName: req.toolName, + title: req.title, + summary: req.summary, + raw: req.raw, + toolInput: req.toolInput, + }, + turnId: turnId ?? null, + }); - const previousModeId: string | null = null; - if (note.update.sessionUpdate === "config_option_update") { - void refreshDroidSessionState(owner, rt, "session_update").then(() => { - persistChatState(owner); - }); - } else if (note.update.sessionUpdate === "session_info_update") { - adoptRuntimeSessionTitle(owner, note.update, "droid_session_info_update"); - } - const turnId = rt.activeTurnId ?? ""; - const resolveTerminal = (tid: string) => { - const t = pooled.terminals.get(tid); - if (!t) return null; - return { - output: t.output, - cwd: t.cwd, - commandLine: t.command, - exited: t.exited, - exitCode: t.exitCode, - truncated: t.truncated, - }; - }; - const events = mapAcpSessionNotificationToChatEvents(note, { turnId, previousModeId }, resolveTerminal); - for (const ev of events) { - if (ev.type === "command") { - const termId = parseAcpTerminalIdFromCommandItemId(ev.itemId); - if (termId && pooled.terminals.has(termId)) { - pooled.terminalWorkLogBindings.set(termId, { - itemId: ev.itemId, - turnId: ev.turnId ?? "", - command: ev.command, - cwd: ev.cwd, - }); - } - } - emitChatEvent(owner, ev); - } - }; - pooled.bridge.onTerminalOutputDelta = (terminalId, acpSessionId) => { - scheduleAcpHostTerminalEmit(pooled, terminalId, acpSessionId); + const wireDroidSdkBridgeHandlers = (managed: ManagedChatSession, runtime: DroidRuntime): void => { + runtime.sdk.bridge.onReady = (ready) => { + if (managed.runtime !== runtime) return; + applyDroidSdkReadyState(managed, runtime, ready); + persistChatState(managed); }; - pooled.bridge.flushTerminalOutput = (terminalId, acpSessionId) => { - const pending = pooled.terminalOutputTimers.get(terminalId); - if (pending) { - clearTimeout(pending); - pooled.terminalOutputTimers.delete(terminalId); - } - emitAcpHostTerminalCommandIfBound(pooled, acpSessionId, terminalId); + runtime.sdk.bridge.onEvent = (event) => { + if (managed.runtime !== runtime) return; + const record = asRecord(event); + if (record?.type === "session_title_updated") { + adoptRuntimeSessionTitle(managed, { title: record.title }, "droid_sdk_session_title_updated"); + } + const events = mapDroidSdkMessageToChatEvents(event, { + turnId: runtime.activeTurnId ?? "", + cwd: managed.laneWorktreePath, + state: runtime.eventMapperState, + }); + for (const ev of events) emitChatEvent(managed, ev); }; - pooled.bridge.onTerminalDisposed = (terminalId) => { - const pending = pooled.terminalOutputTimers.get(terminalId); - if (pending) { - clearTimeout(pending); - pooled.terminalOutputTimers.delete(terminalId); + runtime.sdk.bridge.onPermissionRequest = async (req) => { + if (!managed.runtime || managed.runtime !== runtime) { + return { selectedOption: "cancel", comment: "Droid tool approval is no longer active." }; } - pooled.terminalWorkLogBindings.delete(terminalId); - }; - pooled.bridge.onPermission = async (req) => { - const owner = acpHostSessionOwners.get(req.sessionId); - if (!owner || owner.runtime?.kind !== "droid") { - return { outcome: { outcome: "cancelled" } }; - } - const acpRt = owner.runtime; - // Auto-allow the ADE `ask_user` tool — the inline question card - // provides its own answer UI, and the permission prompt just hides it. - const rawInput = req.toolCall.rawInput as Record | null | undefined; - const rawToolCandidate = rawInput?.name ?? rawInput?.tool ?? rawInput?.toolName; - const rawToolName = typeof rawToolCandidate === "string" ? rawToolCandidate : null; - const toolCallTitle = typeof req.toolCall.title === "string" ? req.toolCall.title : ""; - if (isAutoAllowAskUserEnabled() && (isAskUserToolName(rawToolName) || isAskUserToolName(toolCallTitle))) { - const allow = req.options.find((option) => option.kind === "allow_once" || option.kind === "allow_always"); - if (allow) { - return { outcome: { outcome: "selected", optionId: allow.optionId } }; - } + if (isAutoAllowAskUserEnabled() && isAskUserToolName(req.toolName)) { + const allow = req.options.find((option) => option.value === "proceed_once" || option.value === "proceed_always"); + if (allow) return { selectedOption: allow.value }; } - const itemId = randomUUID(); - const source = "droid"; - return new Promise((outerResolve) => { - acpRt.permissionWaiters.set(itemId, { - options: req.options, - resolve: (resp: RequestPermissionResponse) => { - acpRt.permissionWaiters.delete(itemId); - outerResolve(resp); + const itemId = req.id || randomUUID(); + return new Promise((outerResolve) => { + runtime.permissionWaiters.set(itemId, { + toolName: req.toolName, + request: req, + resolve: (decision) => { + runtime.permissionWaiters.delete(itemId); + outerResolve(decision); }, }); - const request = buildAcpHostPendingInputRequest( - itemId, - req, - source, - acpRt.activeTurnId ?? null, - ); - emitChatEvent(owner, { + const request = buildDroidSdkPendingInputRequest(itemId, req, runtime.activeTurnId ?? null); + emitChatEvent(managed, { type: "approval_request", itemId, kind: "tool_call", - description: req.toolCall.title ?? "Permission required", - turnId: acpRt.activeTurnId ?? undefined, + description: req.summary || req.title || "Droid SDK permission required", + turnId: runtime.activeTurnId ?? undefined, detail: { - acpHost: source, + droidSdk: true, request, - toolCall: req.toolCall, - options: req.options, + hook: req, }, }); }); }; + runtime.sdk.bridge.onAskUserRequest = async (req: DroidSdkAskUserRequest): Promise => { + if (!managed.runtime || managed.runtime !== runtime) { + return { cancelled: true, answers: [] }; + } + const response = await requestChatInput({ + chatSessionId: managed.session.id, + title: req.title, + body: req.questions[0]?.question ?? "Droid needs input before it can continue.", + source: "droid", + providerMetadata: { droidSdk: true, toolCallId: req.toolCallId, raw: req.raw }, + eventDescription: req.title, + eventDetail: { droidSdk: true, askUser: req }, + questions: req.questions.map((question) => ({ + id: question.id, + header: question.header, + question: question.question, + options: question.options, + allowsFreeform: true, + })), + }); + return { + cancelled: response.decision !== "accept" && response.decision !== "accept_for_session", + answers: req.questions.map((question, index) => ({ + index, + question: question.question, + answer: response.answers[question.id]?.join(", ") ?? response.responseText ?? "", + })), + }; + }; }; const wireCursorSdkBridgeHandlers = (managed: ManagedChatSession, runtime: CursorRuntime): void => { @@ -17990,19 +17634,16 @@ export function createAgentChatService(args: { return { sessionId: created.id, session: created }; }; - const droidPoolKeyFor = (managed: ManagedChatSession, resolvedModelId: string): string => { - const launch = resolveDroidAcpLaunchSettings(managed.session); - return [ - managed.session.laneId, - managed.laneWorktreePath, - resolvedModelId, - launch.autonomy, - ].join(":"); - }; + const droidPoolKeyFor = (managed: ManagedChatSession): string => [ + "sdk", + managed.session.id, + managed.session.laneId, + managed.laneWorktreePath, + ].join(":"); const ensureDroidRuntime = async (managed: ManagedChatSession): Promise => { const launchModelId = resolveDroidRuntimeModelId(managed.session); - const poolKey = droidPoolKeyFor(managed, launchModelId); + const poolKey = droidPoolKeyFor(managed); const shouldSyncSessionModel = managed.session.model !== launchModelId || !managed.session.modelId; if (shouldSyncSessionModel) { syncDroidSessionDescriptor(managed, launchModelId); @@ -18011,26 +17652,16 @@ export function createAgentChatService(args: { if (managed.runtime?.kind === "droid") { const existing = managed.runtime; if (existing.poolKey !== poolKey) { - if (existing.acpSessionId) { - acpHostSessionOwners.delete(existing.acpSessionId); - try { - await closeAcpSession(existing.pooled?.connection, existing.acpSessionId); - } catch { - // ignore - } - } for (const [, w] of existing.permissionWaiters) { - cancelCursorPermissionWaiter(w, "Droid tool approval was cancelled because the runtime restarted."); + cancelDroidPermissionWaiter(w, "Droid tool approval was cancelled because the runtime restarted."); } existing.permissionWaiters.clear(); - if (existing.pooled) releaseDroidAcpConnection(existing.poolKey, existing.poolGeneration); + releaseDroidSdkConnection(existing.poolKey, existing.poolGeneration); managed.runtime = null; } else { - if (!existing.pooled) throw new Error("Droid ACP connection not available"); droidRuntimeSetupInterruptRequested.delete(managed); - wireAcpHostBridgeHandlers(existing.pooled); - existing.pooled.bridge.getRootPath = () => managed.laneWorktreePath; - existing.pooled.bridge.getDirtyFileText = getDirtyFileTextForPath; + existing.modelId = launchModelId; + wireDroidSdkBridgeHandlers(managed, existing); await ensureDroidSessionState(managed, existing); persistChatState(managed); return existing; @@ -18052,76 +17683,61 @@ export function createAgentChatService(args: { }; throwIfDroidSetupInterrupted(); - let pooled: DroidAcpPooled | null = null; + let pooled: DroidSdkPooled | null = null; let poolGeneration = 0; let released = false; try { const auth = await detectAuth(); throwIfDroidSetupInterrupted(); - const acquired = await acquireDroidAcpConnection({ + const persisted = readPersistedState(managed.session.id); + const acquired = await acquireDroidSdkConnection({ poolKey, droidPath: resolveDroidExecutable({ auth }).path, workspacePath: managed.laneWorktreePath, - modelId: launchModelId, - launchSettings: resolveDroidAcpLaunchSettings(managed.session), - appVersion, + sessionId: managed.session.id, + resumeSessionId: persisted?.droidSdkSessionId ?? null, + settings: buildDroidSdkSessionSettings(managed, launchModelId), + logger, }); pooled = acquired.pooled; poolGeneration = acquired.generation; throwIfDroidSetupInterrupted(); - wireAcpHostBridgeHandlers(pooled); - pooled.bridge.getRootPath = () => managed.laneWorktreePath; - pooled.bridge.getDirtyFileText = getDirtyFileTextForPath; const rt: DroidRuntime = { kind: "droid", poolKey, poolGeneration, - pooled, - acpSessionId: null, + sdk: pooled, + sdkSessionId: pooled.sdkSessionId, activeTurnId: null, busy: false, interrupted: false, modelId: launchModelId, - currentModelId: null, - availableModelIds: [], - acpModelIdByDisplayKey: new Map(), - displayKeyByAcpModelId: new Map(), + currentModelId: pooled.currentModelId ?? launchModelId, + availableModelIds: pooled.availableModels + .map((entry) => normalizeDroidReportedModelId(entry.id ?? entry.modelId ?? null)) + .filter((entry): entry is string => Boolean(entry)), pendingSteers: [], permissionWaiters: new Map(), + eventMapperState: createDroidSdkEventMapperState(), }; - const persistedAcp = readPersistedState(managed.session.id)?.acpSessionId?.trim(); - if (persistedAcp) { - try { - const resumed = await resumeAcpSession(pooled.connection, acpSessionRequest({ - sessionId: persistedAcp, - cwd: managed.laneWorktreePath, - }) as ResumeSessionRequest); - if (!resumed) throw new Error("Droid ACP agent does not support session resume"); - rt.acpSessionId = persistedAcp; - applyDroidModelSnapshot(managed, rt, resumed); - acpHostSessionOwners.set(persistedAcp, managed); - } catch { - // stale session id — create a new ACP session on first prompt - } - } - throwIfDroidSetupInterrupted(); if (managed.closed) { - releaseDroidAcpConnection(poolKey, poolGeneration); + releaseDroidSdkConnection(poolKey, poolGeneration); released = true; droidRuntimeSetupInterruptRequested.delete(managed); throw new Error("Droid session closed during setup."); } managed.runtime = rt; + wireDroidSdkBridgeHandlers(managed, rt); await ensureDroidSessionState(managed, rt); persistChatState(managed); droidRuntimeSetupInterruptRequested.delete(managed); return rt; } catch (err) { if (!released && pooled && managed.runtime?.kind !== "droid") { - releaseDroidAcpConnection(poolKey, poolGeneration); + releaseDroidSdkConnection(poolKey, poolGeneration); } droidRuntimeSetupInterruptRequested.delete(managed); throw err; @@ -18240,20 +17856,6 @@ export function createAgentChatService(args: { return; } - const promptBlocks = buildAgentPromptBlocks(composed, args.resolvedAttachments); - - if (!runtime.acpSessionId) { - if (!runtime.pooled) throw new Error("Droid ACP connection not available"); - const created = await runtime.pooled.connection.newSession(acpSessionRequest({ - cwd: managed.laneWorktreePath, - }) as Parameters[0]); - const sid = created.sessionId; - runtime.acpSessionId = sid; - applyDroidModelSnapshot(managed, runtime, created); - acpHostSessionOwners.set(sid, managed); - persistChatState(managed); - } - await ensureDroidSessionState(managed, runtime); if (runtime.interrupted) { managed.session.status = "idle"; @@ -18278,62 +17880,69 @@ export function createAgentChatService(args: { durationMs: Date.now() - turnStartedAt, }); - if (!runtime.pooled) throw new Error("Droid ACP connection not available"); - if (args.onDispatched) { args.onDispatched(); args.onDispatched = undefined; } - const promptRes = await runtime.pooled.connection.prompt({ - sessionId: runtime.acpSessionId!, - prompt: promptBlocks, + runtime.eventMapperState = createDroidSdkEventMapperState(); + const droidHarnessPrompt = buildCodingAgentSystemPrompt({ + cwd: managed.laneWorktreePath, + mode: resolveDroidSdkInteractionMode(managed.session) === "spec" ? "planning" : "coding", + permissionMode: toHarnessPermissionMode(managed.session.permissionMode), + interactive: true, + runtime: "droid-sdk", + adeSkillRoots: getAdeAgentSkillRootsForPrompt({ cwd: managed.laneWorktreePath }), + }); + const sdkInput = [ + droidHarnessPrompt, + "", + "## User Request", + composed, + ].join("\n"); + const promptBlocks = buildAgentPromptBlocks(sdkInput, args.resolvedAttachments); + const sdkPromptText = promptBlocks + .filter((block): block is { type: "text"; text: string } => block.type === "text") + .map((block) => block.text) + .join("\n\n"); + const images = promptBlocks + .filter((block): block is { type: "image"; data: string; mimeType: string } => block.type === "image") + .map((block) => ({ data: block.data, mimeType: block.mimeType })); + const result = await runtime.sdk.sendPrompt({ + promptText: sdkPromptText, + ...(images.length ? { images } : {}), + settings: buildDroidSdkSessionSettings(managed, runtime.modelId), }); - - await refreshDroidSessionState(managed, runtime, "after_prompt"); - persistDeliveredLaneDirectiveKey(managed, args.laneDirectiveKey); const descriptor = resolveSessionModelDescriptor(managed.session); - const usage = promptRes.usage - ? { - inputTokens: promptRes.usage.inputTokens, - outputTokens: promptRes.usage.outputTokens, - cacheReadTokens: promptRes.usage.cachedReadTokens ?? null, - cacheCreationTokens: promptRes.usage.cachedWriteTokens ?? null, - } - : undefined; + const doneEvent = mapDroidSdkRunResultToDoneEvent(result, { + turnId, + model: managed.session.model, + ...(managed.session.modelId + ? { modelId: managed.session.modelId } + : descriptor + ? { modelId: descriptor.id } + : {}), + state: runtime.eventMapperState, + interrupted: runtime.interrupted, + }); void emitTurnDiffSummaryIfChanged(managed, turnId); - if (runtime.interrupted || promptRes.stopReason === "cancelled") { + if (runtime.interrupted || doneEvent.status === "interrupted") { markSessionIdleWithFreshCache(managed); cancelQueuedSteers(managed, runtime, "interrupted"); emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); - for (const ev of mapStopReasonToTerminalEvents({ - stopReason: "cancelled", - turnId, - model: managed.session.model, - ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), - usage, - })) { - emitChatEvent(managed, ev); - } + emitChatEvent(managed, { ...doneEvent, status: "interrupted" }); + } else if (doneEvent.status === "failed") { + markSessionIdleWithFreshCache(managed); + cancelQueuedSteers(managed, runtime, "failed"); + emitChatEvent(managed, { type: "status", turnStatus: "failed", turnId }); + emitChatEvent(managed, doneEvent); } else { markSessionIdleWithFreshCache(managed); emitChatEvent(managed, { type: "status", turnStatus: "completed", turnId }); - for (const ev of mapStopReasonToTerminalEvents({ - stopReason: promptRes.stopReason, - turnId, - model: managed.session.model, - ...(managed.session.modelId - ? { modelId: managed.session.modelId } - : descriptor - ? { modelId: descriptor.id } - : {}), - usage, - })) { - emitChatEvent(managed, ev); - } + emitChatEvent(managed, doneEvent); shouldDeliverQueuedSteer = runtime.pendingSteers.length > 0; } @@ -18345,17 +17954,20 @@ export function createAgentChatService(args: { } catch (error) { markSessionIdleWithFreshCache(managed); const descriptor = resolveSessionModelDescriptor(managed.session); - const acpError = classifyAcpHostError( + const classified = classifyAcpHostError( error, "Factory Droid", - descriptor?.displayName ?? managed.session.model, + descriptor?.displayName ?? managed.session.model ?? "Droid", ); - const msg = acpError.message; + const msg = classified.message; const treatAsInterrupt = - runtime.interrupted || msg === "Droid session closed during setup."; + runtime.interrupted + || msg === "Droid session closed during setup." + || msg.toLowerCase().includes("abort") + || msg.toLowerCase().includes("interrupt"); for (const [, w] of runtime.permissionWaiters) { - cancelCursorPermissionWaiter(w, "Droid tool approval was cancelled because the turn failed."); + cancelDroidPermissionWaiter(w, "Droid tool approval was cancelled because the turn failed."); } runtime.permissionWaiters.clear(); @@ -18376,8 +17988,8 @@ export function createAgentChatService(args: { emitChatEvent(managed, { type: "error", message: msg, - ...(acpError.detail ? { detail: acpError.detail } : {}), - errorInfo: acpError.errorInfo, + ...(classified.detail ? { detail: classified.detail } : {}), + errorInfo: classified.errorInfo, turnId, }); emitChatEvent(managed, { type: "status", turnStatus: "failed", turnId }); @@ -19358,15 +18970,13 @@ export function createAgentChatService(args: { if (managed.runtime?.kind === "droid") { const rt = managed.runtime; rt.interrupted = true; - if (rt.acpSessionId) { - try { - await rt.pooled?.connection.cancel({ sessionId: rt.acpSessionId }); - } catch { - // ignore - } + try { + await rt.sdk.cancel(); + } catch { + // ignore } for (const [, w] of rt.permissionWaiters) { - cancelCursorPermissionWaiter(w, "Droid tool approval was cancelled because the turn was interrupted."); + cancelDroidPermissionWaiter(w, "Droid tool approval was cancelled because the turn was interrupted."); } rt.permissionWaiters.clear(); cancelQueuedSteers(managed, rt, "interrupted"); @@ -20070,8 +19680,9 @@ export function createAgentChatService(args: { return; } - if (managed.runtime?.kind === "cursor" || managed.runtime?.kind === "droid") { - const pending = managed.runtime.permissionWaiters.get(itemId); + const cursorRuntime = managed.runtime?.kind === "cursor" ? managed.runtime : null; + if (cursorRuntime) { + const pending = cursorRuntime.permissionWaiters.get(itemId); if (!pending) { // Treat missing waiter as a benign race (e.g. the Cursor turn already // resolved or was cancelled before the user responded). Simply no-op. @@ -20081,29 +19692,43 @@ export function createAgentChatService(args: { }); return; } - managed.runtime.permissionWaiters.delete(itemId); + cursorRuntime.permissionWaiters.delete(itemId); if (pending.sdkHook) { - if (managed.runtime.kind !== "cursor") { - pending.resolve(denyCursorHook("ADE no longer has an active Cursor SDK runtime for this tool approval.")); - emitPendingInputResolved(managed, { - itemId, - decision: "cancel", - turnId: managed.runtime.activeTurnId ?? null, - }); - return; - } if (resolvedDecision === "accept_for_session") { - managed.runtime.sdkApprovedTools.add(normalizeCursorSdkToolName(pending.toolName)); + cursorRuntime.sdkApprovedTools.add(normalizeCursorSdkToolName(pending.toolName)); } pending.resolve(mapChatDecisionToCursorSdkHook(resolvedDecision)); emitPendingInputResolved(managed, { itemId, decision: resolvedDecision, - turnId: managed.runtime.activeTurnId ?? null, + turnId: cursorRuntime.activeTurnId ?? null, }); return; } pending.resolve(mapChatDecisionToCursorPermission(resolvedDecision, pending.options, answers)); + emitPendingInputResolved(managed, { + itemId, + decision: resolvedDecision, + turnId: cursorRuntime.activeTurnId ?? null, + }); + return; + } + + if (managed.runtime?.kind === "droid") { + const pending = managed.runtime.permissionWaiters.get(itemId); + if (!pending) { + logger.debug("agent_chat.droid_permission_waiter_missing", { + sessionId, + itemId, + }); + return; + } + managed.runtime.permissionWaiters.delete(itemId); + pending.resolve(mapChatDecisionToDroidPermission( + resolvedDecision, + pending.request, + answers, + )); emitPendingInputResolved(managed, { itemId, decision: resolvedDecision, @@ -20195,12 +19820,12 @@ export function createAgentChatService(args: { try { const auth = await detectAuth(); const droidPath = resolveDroidExecutable({ auth }).path; - const ordered = await discoverDroidCliModelDescriptors(droidPath); + const ordered = await discoverDroidSdkModelDescriptors(droidPath); const preferred = pickDefaultDroidDescriptorFromCliList(ordered); return ordered.map((d) => ({ id: d.id, displayName: d.displayName, - description: `${d.displayName} (Factory Droid CLI)`, + description: `${d.displayName} (Factory Droid SDK)`, isDefault: preferred ? d.id === preferred.id : false, reasoningEfforts: d.reasoningTiers?.map((tier) => ({ effort: tier, @@ -20510,12 +20135,10 @@ export function createAgentChatService(args: { if (managed.runtime?.kind === "droid") { managed.runtime.interrupted = true; cancelQueuedSteers(managed, managed.runtime, "disposed"); - if (managed.runtime.acpSessionId) { - try { - await managed.runtime.pooled?.connection.cancel({ sessionId: managed.runtime.acpSessionId }); - } catch { - // ignore - } + try { + await managed.runtime.sdk.cancel(); + } catch { + // ignore } } @@ -21093,16 +20716,6 @@ export function createAgentChatService(args: { if (managed.runtime?.kind === "droid" && managed.runtime.busy) return; const runtime = await ensureDroidRuntime(managed); - if (!runtime.pooled) return; - if (!runtime.acpSessionId) { - const created = await runtime.pooled.connection.newSession(acpSessionRequest({ - cwd: managed.laneWorktreePath, - }) as Parameters[0]); - const sid = created.sessionId; - runtime.acpSessionId = sid; - applyDroidModelSnapshot(managed, runtime, created); - acpHostSessionOwners.set(sid, managed); - } await ensureDroidSessionState(managed, runtime); persistChatState(managed); return; diff --git a/apps/desktop/src/main/services/chat/droidAcpPool.ts b/apps/desktop/src/main/services/chat/droidAcpPool.ts deleted file mode 100644 index 51310149c..000000000 --- a/apps/desktop/src/main/services/chat/droidAcpPool.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type { ClientSideConnection, InitializeResponse } from "@agentclientprotocol/sdk"; -import type { AcpHostBridge, AcpHostTermState } from "./acpHostClient"; -import { acquireAcpCliConnection, hasActiveAcpCliPoolEntry, releaseAcpCliConnection } from "./acpCliPool"; - -export type DroidAcpBridge = AcpHostBridge; - -export type DroidAcpLaunchSettings = { - /** Maps ADE unified permission / plan mode to Droid exec autonomy. */ - autonomy: "none" | "low" | "medium" | "high"; -}; - -export type DroidTerminalWorkLogBinding = { - itemId: string; - turnId: string; - command: string; - cwd: string; -}; - -export type DroidAcpPooled = { - connection: ClientSideConnection; - bridge: DroidAcpBridge; - terminals: Map; - terminalWorkLogBindings: Map; - terminalOutputTimers: Map>; - dispose: () => void; -}; - -let droidGenCounter = 0; -const droidPools = new Map(); -const pendingDroidInit = new Map>(); - -function internalPoolKey(poolKey: string): string { - return `droid:${poolKey}`; -} - -function clearDroidTerminalTimers(pooled: DroidAcpPooled): void { - for (const h of pooled.terminalOutputTimers.values()) { - clearTimeout(h); - } - pooled.terminalOutputTimers.clear(); -} - -export async function acquireDroidAcpConnection(args: { - poolKey: string; - droidPath: string; - workspacePath: string; - modelId: string; - launchSettings: DroidAcpLaunchSettings; - appVersion: string; -}): Promise<{ pooled: DroidAcpPooled; generation: number }> { - const spawnArgs = [ - "exec", - "--output-format", - "acp", - "--cwd", - args.workspacePath, - "-m", - args.modelId, - ]; - if (args.launchSettings.autonomy !== "none") { - spawnArgs.push("--auto", args.launchSettings.autonomy); - } - - const acpOptions = { - poolKey: internalPoolKey(args.poolKey), - logPrefix: "[DroidAcpPool]", - appVersion: args.appVersion, - spawn: { - command: args.droidPath, - args: spawnArgs, - cwd: args.workspacePath, - env: { ...process.env } as NodeJS.ProcessEnv, - }, - afterInitialize: async (_args: { connection: ClientSideConnection; initResult: InitializeResponse }) => { - // Droid auth is typically via FACTORY_API_KEY or Factory CLI config — no ACP authenticate step today. - }, - }; - - const innerKey = internalPoolKey(args.poolKey); - const staleOuter = droidPools.get(args.poolKey); - if (staleOuter && !hasActiveAcpCliPoolEntry(innerKey)) { - droidPools.delete(args.poolKey); - } - - const existing = droidPools.get(args.poolKey); - if (existing && hasActiveAcpCliPoolEntry(innerKey)) { - await acquireAcpCliConnection(acpOptions); - existing.ref += 1; - return { pooled: existing.pooled, generation: existing.generation }; - } - - // Existing entry is stale — clean its terminal timers and remove before creating a new one. - if (existing) { - clearDroidTerminalTimers(existing.pooled); - droidPools.delete(args.poolKey); - } - - let initOwner = false; - let init = pendingDroidInit.get(args.poolKey); - if (!init) { - initOwner = true; - init = (async () => { - const base = await acquireAcpCliConnection(acpOptions); - - const terminalWorkLogBindings = new Map(); - const terminalOutputTimers = new Map>(); - - const pooled: DroidAcpPooled = { - connection: base.connection, - bridge: base.bridge, - terminals: base.terminals, - terminalWorkLogBindings, - terminalOutputTimers, - dispose: base.dispose, - }; - - const generation = ++droidGenCounter; - droidPools.set(args.poolKey, { ref: 1, generation, pooled }); - return pooled; - })().finally(() => { - pendingDroidInit.delete(args.poolKey); - }); - pendingDroidInit.set(args.poolKey, init); - } - - const pooled = await init; - // Pending-init waiters might race with the inner ACP pool being torn down - // between the awaited init and this branch. Verify the entry still matches - // the generation we awaited before attaching a ref. - if (!initOwner) { - const liveEntry = droidPools.get(args.poolKey); - if (!liveEntry || !hasActiveAcpCliPoolEntry(innerKey) || liveEntry.pooled !== pooled) { - return acquireDroidAcpConnection(args); - } - await acquireAcpCliConnection(acpOptions); - liveEntry.ref += 1; - } - const entry = droidPools.get(args.poolKey); - return { pooled, generation: entry?.generation ?? 0 }; -} - -export function releaseDroidAcpConnection(poolKey: string, generation?: number): void { - const entry = droidPools.get(poolKey); - if (!entry) return; - if (generation !== undefined && entry.generation !== generation) return; - entry.ref -= 1; - if (entry.ref < 0) entry.ref = 0; - releaseAcpCliConnection(internalPoolKey(poolKey)); - if (entry.ref <= 0) { - clearDroidTerminalTimers(entry.pooled); - droidPools.delete(poolKey); - } -} diff --git a/apps/desktop/src/main/services/chat/droidModelsDiscovery.test.ts b/apps/desktop/src/main/services/chat/droidModelsDiscovery.test.ts index 710f251f8..bcef15721 100644 --- a/apps/desktop/src/main/services/chat/droidModelsDiscovery.test.ts +++ b/apps/desktop/src/main/services/chat/droidModelsDiscovery.test.ts @@ -1,5 +1,42 @@ -import { describe, expect, it } from "vitest"; -import { parseDroidExecHelpModelIds, parseDroidExecHelpModels } from "./droidModelsDiscovery"; +import fs from "node:fs"; +import path from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockCreateSession = vi.hoisted(() => vi.fn()); +const mockHome = vi.hoisted(() => ({ path: "" })); + +vi.mock("@factory/droid-sdk", () => ({ + createSession: mockCreateSession, +})); + +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as object), + homedir: () => mockHome.path, + }; +}); + +import { + clearDroidCliModelsCache, + discoverDroidCliModelDescriptors, + parseDroidExecHelpModelIds, + parseDroidExecHelpModels, +} from "./droidModelsDiscovery"; + +let tmpHome: string; + +beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(tmpdir(), "ade-droid-models-")); + mockHome.path = tmpHome; + clearDroidCliModelsCache(); + mockCreateSession.mockReset(); +}); + +afterEach(() => { + fs.rmSync(tmpHome, { recursive: true, force: true }); +}); describe("parseDroidExecHelpModelIds", () => { it("parses built-in and custom models from droid exec help", () => { @@ -51,3 +88,78 @@ describe("parseDroidExecHelpModels", () => { ]); }); }); + +describe("discoverDroidCliModelDescriptors", () => { + it("uses the Droid SDK model catalog before fallback models", async () => { + const close = vi.fn(async () => {}); + mockCreateSession.mockResolvedValueOnce({ + initResult: { + availableModels: [ + { + id: "claude-sonnet-4-6", + displayName: "Claude Sonnet 4.6", + }, + { + id: "custom:gpt-5.4(xhigh)", + displayName: "GPT-5.4 (XHigh)", + isCustom: true, + }, + ], + }, + close, + }); + + const descriptors = await discoverDroidCliModelDescriptors("/mock/bin/droid"); + + expect(mockCreateSession).toHaveBeenCalledWith(expect.objectContaining({ + execPath: "/mock/bin/droid", + })); + expect(close).toHaveBeenCalled(); + expect(descriptors.map((descriptor) => descriptor.id)).toEqual([ + "droid/claude-sonnet-4-6", + "droid/custom:gpt-5.4(xhigh)", + ]); + expect(descriptors[1]).toMatchObject({ + displayName: "GPT-5.4 (XHigh)", + customProxy: true, + }); + }); + + it("merges existing Factory config custom models with SDK models", async () => { + fs.mkdirSync(path.join(tmpHome, ".factory"), { recursive: true }); + fs.writeFileSync( + path.join(tmpHome, ".factory", "config.json"), + JSON.stringify({ + custom_models: [ + { + model: "claude-sonnet-4-6-thinking-32000", + model_display_name: "Claude Sonnet 4.6 (High)", + }, + ], + }), + "utf8", + ); + mockCreateSession.mockResolvedValueOnce({ + initResult: { + availableModels: [ + { + id: "claude-sonnet-4-6", + displayName: "Claude Sonnet 4.6", + }, + ], + }, + close: vi.fn(async () => {}), + }); + + const descriptors = await discoverDroidCliModelDescriptors("/mock/bin/droid"); + + expect(descriptors.map((descriptor) => descriptor.id)).toEqual([ + "droid/claude-sonnet-4-6", + "droid/custom:claude-sonnet-4-6-thinking-32000", + ]); + expect(descriptors[1]).toMatchObject({ + displayName: "Claude Sonnet 4.6 (High)", + customProxy: true, + }); + }); +}); diff --git a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts index f9a627595..20551dd96 100644 --- a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts +++ b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts @@ -1,6 +1,7 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { homedir } from "node:os"; +import { createSession } from "@factory/droid-sdk"; import { createDynamicDroidCliModelDescriptor, sortDroidCliDescriptorsForPicker, @@ -181,6 +182,57 @@ async function listDroidModelsFromCliInner(droidPath: string): Promise : null; + const raw = Array.isArray(record?.availableModels) ? record.availableModels : []; + const seen = new Set(); + const rows: DroidExecHelpModelRow[] = []; + for (const entry of raw) { + const model = entry && typeof entry === "object" ? entry as Record : null; + if (!model) continue; + const id = typeof model.id === "string" && model.id.trim().length + ? model.id.trim() + : typeof model.modelId === "string" + ? model.modelId.trim() + : ""; + if (!id || seen.has(id)) continue; + seen.add(id); + const displayName = typeof model.displayName === "string" && model.displayName.trim().length + ? model.displayName.trim() + : typeof model.shortDisplayName === "string" && model.shortDisplayName.trim().length + ? model.shortDisplayName.trim() + : id; + rows.push({ + id, + displayName, + customProxy: model.isCustom === true, + }); + } + return rows; +} + +async function listDroidModelsFromSdk(droidPath: string): Promise { + const now = Date.now(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8_000); + try { + const session = await createSession({ + execPath: droidPath, + cwd: process.cwd(), + abortSignal: controller.signal, + }); + try { + const rows = readSdkModelRows(session.initResult); + cached = { at: now, models: rows }; + return rows; + } finally { + await session.close().catch(() => undefined); + } + } finally { + clearTimeout(timeout); + } +} + export function clearDroidCliModelsCache(): void { cached = null; inflight = null; @@ -230,11 +282,11 @@ export async function discoverDroidCliModelDescriptors( droidPath: string, options?: { mode?: DroidCliModelDiscoveryMode }, ): Promise { - const fromCli = options?.mode === "cached-or-fallback" + const fromSdk = options?.mode === "cached-or-fallback" ? getCachedDroidModels() ?? [] - : await listDroidModelsFromCli(droidPath); - const baseRows: DroidExecHelpModelRow[] = fromCli.length - ? fromCli + : await listDroidModelsFromSdk(droidPath).catch(() => []); + const baseRows: DroidExecHelpModelRow[] = fromSdk.length + ? fromSdk : DROID_DEFAULT_MODEL_IDS.map((id) => ({ id, displayName: id })); // Merge custom models from ~/.factory/config.json so vibeproxy-injected @@ -251,3 +303,5 @@ export async function discoverDroidCliModelDescriptors( } return sortDroidCliDescriptorsForPicker(descriptors); } + +export const discoverDroidSdkModelDescriptors = discoverDroidCliModelDescriptors; diff --git a/apps/desktop/src/main/services/chat/droidSdkEventMapper.ts b/apps/desktop/src/main/services/chat/droidSdkEventMapper.ts new file mode 100644 index 000000000..7ed4dc169 --- /dev/null +++ b/apps/desktop/src/main/services/chat/droidSdkEventMapper.ts @@ -0,0 +1,259 @@ +import type { AgentChatEvent } from "../../../shared/types"; + +type SdkRecord = Record; + +export type DroidSdkEventMapperState = { + assistantDeltaItemIds: Set; + thinkingDeltaItemIds: Set; + toolNamesByUseId: Map; + latestUsage: { + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheCreationTokens?: number; + } | null; +}; + +export function createDroidSdkEventMapperState(): DroidSdkEventMapperState { + return { + assistantDeltaItemIds: new Set(), + thinkingDeltaItemIds: new Set(), + toolNamesByUseId: new Map(), + latestUsage: null, + }; +} + +function asRecord(value: unknown): SdkRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as SdkRecord : null; +} + +function readString(value: unknown): string | null { + const text = typeof value === "string" ? value : ""; + return text.length ? text : null; +} + +function readNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function itemIdFor(record: SdkRecord, kind: "text" | "thinking"): string { + const messageId = readString(record.messageId) ?? `droid-${kind}`; + const blockIndex = readNumber(record.blockIndex) ?? 0; + return `${messageId}:${kind}:${blockIndex}`; +} + +function summarize(value: unknown): string { + if (typeof value === "string") return value; + if (value == null) return ""; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function extractCommand(args: unknown): string | null { + const record = asRecord(args); + return readString(record?.command) + ?? readString(record?.fullCommand) + ?? readString(record?.cmd) + ?? readString(record?.shellCommand); +} + +function extractCwd(args: unknown, fallback: string): string { + const record = asRecord(args); + return readString(record?.cwd) ?? readString(record?.workingDirectory) ?? fallback; +} + +function toolResultStatus(event: SdkRecord): "completed" | "failed" { + return event.isError === true ? "failed" : "completed"; +} + +function extractTextBlocks(content: unknown): Array<{ text: string; kind: "text" | "thinking"; id?: string }> { + if (!Array.isArray(content)) return []; + const out: Array<{ text: string; kind: "text" | "thinking"; id?: string }> = []; + for (const block of content) { + const record = asRecord(block); + if (!record) continue; + const type = readString(record.type); + const text = readString(record.text); + if (!text) continue; + out.push({ + text, + kind: type === "thinking" ? "thinking" : "text", + ...(readString(record.id) ? { id: readString(record.id)! } : {}), + }); + } + return out; +} + +function usageFrom(record: SdkRecord | null): DroidSdkEventMapperState["latestUsage"] { + if (!record) return null; + const inputTokens = readNumber(record.inputTokens); + const outputTokens = readNumber(record.outputTokens); + const cacheReadTokens = readNumber(record.cacheReadTokens); + const cacheCreationTokens = readNumber(record.cacheCreationTokens); + const usage = { + ...(inputTokens != null ? { inputTokens } : {}), + ...(outputTokens != null ? { outputTokens } : {}), + ...(cacheReadTokens != null ? { cacheReadTokens } : {}), + ...(cacheCreationTokens != null ? { cacheCreationTokens } : {}), + }; + return Object.keys(usage).length ? usage : null; +} + +export function mapDroidSdkMessageToChatEvents( + message: unknown, + meta: { + turnId: string; + cwd: string; + state: DroidSdkEventMapperState; + }, +): AgentChatEvent[] { + const record = asRecord(message); + if (!record) return []; + const type = readString(record.type); + const turnId = meta.turnId; + + switch (type) { + case "assistant_text_delta": { + const text = readString(record.text); + if (!text) return []; + const itemId = itemIdFor(record, "text"); + meta.state.assistantDeltaItemIds.add(itemId); + return [{ type: "text", text, itemId, turnId }]; + } + case "thinking_text_delta": { + const text = readString(record.text); + if (!text) return []; + const itemId = itemIdFor(record, "thinking"); + meta.state.thinkingDeltaItemIds.add(itemId); + return [{ type: "reasoning", text, itemId, turnId }]; + } + case "create_message": { + const role = readString(record.role); + if (role !== "assistant") return []; + return extractTextBlocks(record.content).flatMap((block, index): AgentChatEvent[] => { + const itemId = block.id ?? `${readString(record.messageId) ?? "droid-message"}:${block.kind}:${index}`; + if (block.kind === "thinking") { + if (meta.state.thinkingDeltaItemIds.has(itemId)) return []; + return [{ type: "reasoning", text: block.text, itemId, turnId }]; + } + if (meta.state.assistantDeltaItemIds.has(itemId)) return []; + return [{ type: "text", text: block.text, itemId, turnId }]; + }); + } + case "tool_use": { + const toolUseId = readString(record.toolUseId) ?? `droid-tool-${Date.now()}`; + const tool = readString(record.toolName) ?? "tool"; + meta.state.toolNamesByUseId.set(toolUseId, tool); + const command = extractCommand(record.toolInput); + if (command) { + return [{ + type: "command", + command, + cwd: extractCwd(record.toolInput, meta.cwd), + output: "", + itemId: toolUseId, + turnId, + status: "running", + }]; + } + return [{ type: "tool_call", tool, args: record.toolInput ?? {}, itemId: toolUseId, turnId }]; + } + case "tool_progress": { + const toolUseId = readString(record.toolUseId) ?? `droid-tool-${Date.now()}`; + const tool = readString(record.toolName) ?? meta.state.toolNamesByUseId.get(toolUseId) ?? "tool"; + const content = readString(record.content) ?? summarize(record.update); + return [{ + type: "tool_result", + tool, + result: content, + itemId: `${toolUseId}:progress`, + logicalItemId: toolUseId, + turnId, + status: "running", + }]; + } + case "tool_result": { + const toolUseId = readString(record.toolUseId) ?? `droid-tool-${Date.now()}`; + const tool = readString(record.toolName) ?? meta.state.toolNamesByUseId.get(toolUseId) ?? "tool"; + return [{ + type: "tool_result", + tool, + result: record.content, + itemId: toolUseId, + turnId, + status: toolResultStatus(record), + }]; + } + case "working_state_changed": { + const state = readString(record.state); + if (!state || state.toLowerCase() === "idle") return []; + return [{ + type: "activity", + activity: "working", + detail: `Droid ${state}`, + turnId, + }]; + } + case "token_usage_update": { + const usage = usageFrom(record); + meta.state.latestUsage = usage; + if (!usage) return []; + return [{ + type: "tokens", + turnId, + ...(usage.inputTokens != null ? { inputTokens: usage.inputTokens } : {}), + ...(usage.outputTokens != null ? { outputTokens: usage.outputTokens } : {}), + ...(usage.cacheReadTokens != null ? { cacheReadTokens: usage.cacheReadTokens } : {}), + ...(usage.cacheCreationTokens != null ? { cacheWriteTokens: usage.cacheCreationTokens } : {}), + }]; + } + case "session_title_updated": + case "settings_updated": + case "permission_resolved": + case "turn_complete": + case "mcp_status_changed": + case "mcp_auth_required": + case "mcp_auth_completed": + case "mission_state_changed": + case "mission_features_changed": + case "mission_progress_entry": + case "mission_heartbeat": + case "mission_worker_started": + case "mission_worker_completed": + return []; + case "error": + return [{ + type: "error", + message: readString(record.message) ?? "Droid SDK reported an error.", + turnId, + }]; + default: + return []; + } +} + +export function mapDroidSdkRunResultToDoneEvent( + result: unknown, + meta: { + turnId: string; + model: string; + modelId?: string; + state: DroidSdkEventMapperState; + interrupted?: boolean; + }, +): Extract { + const record = asRecord(result); + const tokenUsage = asRecord(record?.tokenUsage) ?? meta.state.latestUsage; + const usage = usageFrom(tokenUsage); + return { + type: "done", + turnId: meta.turnId, + status: meta.interrupted ? "interrupted" : record?.success === false ? "failed" : "completed", + model: meta.model, + ...(meta.modelId ? { modelId: meta.modelId } : {}), + ...(usage ? { usage } : {}), + }; +} diff --git a/apps/desktop/src/main/services/chat/droidSdkPool.ts b/apps/desktop/src/main/services/chat/droidSdkPool.ts new file mode 100644 index 000000000..5820be728 --- /dev/null +++ b/apps/desktop/src/main/services/chat/droidSdkPool.ts @@ -0,0 +1,270 @@ +import { fork, type ChildProcess } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import type { Logger } from "../logging/logger"; +import type { + DroidSdkAskUserRequest, + DroidSdkAskUserResponse, + DroidSdkPermissionDecision, + DroidSdkPermissionRequest, + DroidSdkReady, + DroidSdkSendPrompt, + DroidSdkSessionSettings, + DroidSdkWorkerInit, + DroidSdkWorkerRequest, + DroidSdkWorkerResponse, +} from "./droidSdkProtocol"; + +type PendingRpc = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + type: DroidSdkWorkerRequest["type"]; +}; + +export type DroidSdkBridge = { + onEvent: ((event: unknown) => void) | null; + onPermissionRequest: ((request: DroidSdkPermissionRequest) => Promise) | null; + onAskUserRequest: ((request: DroidSdkAskUserRequest) => Promise) | null; + onReady: ((ready: DroidSdkReady) => void) | null; +}; + +export type DroidSdkPooled = { + process: ChildProcess; + bridge: DroidSdkBridge; + sdkSessionId: string | null; + currentModelId: string | null; + availableModels: DroidSdkReady["availableModels"]; + request: (type: DroidSdkWorkerRequest["type"], payload?: unknown) => Promise; + sendPrompt: (payload: DroidSdkSendPrompt) => Promise; + updateSettings: (settings: DroidSdkSessionSettings) => Promise; + cancel: () => Promise; + dispose: () => void; +}; + +let droidSdkGenCounter = 0; +const pools = new Map(); +const pendingInits = new Map>(); + +function resolveWorkerPath(): string { + const candidates = [ + path.join(__dirname, "droidSdkWorker.cjs"), + path.join(process.cwd(), "dist", "main", "droidSdkWorker.cjs"), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + return candidates[0]!; +} + +function sanitizeEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return { ...base }; +} + +export async function acquireDroidSdkConnection(args: { + poolKey: string; + droidPath: string; + workspacePath: string; + sessionId: string; + resumeSessionId?: string | null; + settings: DroidSdkSessionSettings; + logger?: Logger; +}): Promise<{ pooled: DroidSdkPooled; generation: number }> { + const existing = pools.get(args.poolKey); + if (existing && existing.pooled.process.exitCode == null && !existing.pooled.process.killed) { + existing.ref += 1; + return { pooled: existing.pooled, generation: existing.generation }; + } + if (existing) pools.delete(args.poolKey); + + let initOwner = false; + let init = pendingInits.get(args.poolKey); + if (!init) { + initOwner = true; + init = createDroidSdkConnection(args).finally(() => pendingInits.delete(args.poolKey)); + pendingInits.set(args.poolKey, init); + } + + const pooled = await init; + if (!initOwner) { + const live = pools.get(args.poolKey); + if (live?.pooled === pooled) live.ref += 1; + } + const entry = pools.get(args.poolKey); + return { pooled, generation: entry?.generation ?? 0 }; +} + +async function createDroidSdkConnection(args: Parameters[0]): Promise { + const child = fork(resolveWorkerPath(), [], { + cwd: args.workspacePath, + env: sanitizeEnv(process.env), + stdio: ["ignore", "pipe", "pipe", "ipc"], + execArgv: [], + }); + const pending = new Map(); + const bridge: DroidSdkBridge = { + onEvent: null, + onPermissionRequest: null, + onAskUserRequest: null, + onReady: null, + }; + + child.stdout?.on("data", (chunk) => { + const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); + if (text.trim()) args.logger?.debug("agent_chat.droid_sdk_worker_stdout", { text: text.trim() }); + }); + child.stderr?.on("data", (chunk) => { + const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); + if (text.trim()) args.logger?.warn("agent_chat.droid_sdk_worker_stderr", { text: text.trim() }); + }); + + const pooled: DroidSdkPooled = { + process: child, + bridge, + sdkSessionId: null, + currentModelId: null, + availableModels: [], + request: (type: DroidSdkWorkerRequest["type"], payload?: unknown) => { + const requestId = randomUUID(); + return new Promise((resolve, reject) => { + pending.set(requestId, { + resolve: (value) => resolve(value as T), + reject, + type, + }); + child.send?.({ type, requestId, payload } as DroidSdkWorkerRequest); + }); + }, + sendPrompt: (payload) => pooled.request("send", payload), + updateSettings: (settings) => pooled.request("settings_update", settings), + cancel: () => pooled.request("cancel"), + dispose: () => { + for (const [, waiter] of pending) waiter.reject(new Error("Droid SDK worker disposed.")); + pending.clear(); + try { + child.send?.({ type: "dispose", requestId: randomUUID() } as DroidSdkWorkerRequest); + } catch { + // ignore + } + setTimeout(() => { + if (child.exitCode == null && !child.killed) child.kill("SIGTERM"); + }, 800).unref(); + }, + }; + + child.on("message", (raw: unknown) => { + const message = raw as DroidSdkWorkerResponse; + if (!message || typeof message !== "object" || !("type" in message)) return; + if (message.type === "response") { + const waiter = pending.get(message.requestId); + if (!waiter) return; + pending.delete(message.requestId); + if (message.ok) waiter.resolve(message.result); + else waiter.reject(new Error(`Droid SDK ${waiter.type} failed: ${message.error || "unknown error"}`)); + return; + } + if (message.type === "ready") { + pooled.sdkSessionId = message.ready.sessionId; + pooled.currentModelId = message.ready.currentModelId; + pooled.availableModels = message.ready.availableModels; + bridge.onReady?.(message.ready); + return; + } + if (message.type === "sdk_event") { + bridge.onEvent?.(message.event); + return; + } + if (message.type === "permission_request") { + void (async () => { + let decision: DroidSdkPermissionDecision; + try { + decision = bridge.onPermissionRequest + ? await bridge.onPermissionRequest(message.request) + : { selectedOption: "cancel", comment: "ADE is not ready to approve Droid tool calls." }; + } catch (error) { + args.logger?.error("agent_chat.droid_sdk_permission_error", { + error: error instanceof Error ? error.message : String(error), + }); + decision = { selectedOption: "cancel", comment: "Droid permission evaluation failed." }; + } + child.send?.({ + type: "permission_response", + requestId: message.requestId, + payload: decision, + } as DroidSdkWorkerRequest); + })(); + return; + } + if (message.type === "ask_user_request") { + void (async () => { + let response: DroidSdkAskUserResponse; + try { + response = bridge.onAskUserRequest + ? await bridge.onAskUserRequest(message.request) + : { cancelled: true, answers: [] }; + } catch (error) { + args.logger?.error("agent_chat.droid_sdk_ask_user_error", { + error: error instanceof Error ? error.message : String(error), + }); + response = { cancelled: true, answers: [] }; + } + child.send?.({ + type: "ask_user_response", + requestId: message.requestId, + payload: response, + } as DroidSdkWorkerRequest); + })(); + return; + } + if (message.type === "log") { + const level = message.level === "error" ? "warn" : message.level; + args.logger?.[level]?.("agent_chat.droid_sdk_worker_log", { + message: message.message, + detail: message.detail, + }); + } + }); + + child.on("exit", (code, signal) => { + for (const [, waiter] of pending) { + waiter.reject(new Error(`Droid SDK worker exited (${code ?? signal ?? "unknown"}).`)); + } + pending.clear(); + for (const [poolKey, entry] of pools) { + if (entry.pooled === pooled) pools.delete(poolKey); + } + }); + + const initPayload: DroidSdkWorkerInit = { + sessionId: args.sessionId, + laneRoot: args.workspacePath, + droidPath: args.droidPath, + resumeSessionId: args.resumeSessionId ?? null, + settings: args.settings, + }; + let ready: DroidSdkReady; + try { + ready = await pooled.request("init", initPayload); + } catch (error) { + pooled.dispose(); + throw error; + } + pooled.sdkSessionId = ready.sessionId; + pooled.currentModelId = ready.currentModelId; + pooled.availableModels = ready.availableModels; + const generation = ++droidSdkGenCounter; + pools.set(args.poolKey, { ref: 1, generation, pooled }); + return pooled; +} + +export function releaseDroidSdkConnection(poolKey: string, generation?: number): void { + const entry = pools.get(poolKey); + if (!entry) return; + if (generation !== undefined && entry.generation !== generation) return; + entry.ref -= 1; + if (entry.ref < 0) entry.ref = 0; + if (entry.ref <= 0) { + entry.pooled.dispose(); + pools.delete(poolKey); + } +} diff --git a/apps/desktop/src/main/services/chat/droidSdkProtocol.ts b/apps/desktop/src/main/services/chat/droidSdkProtocol.ts new file mode 100644 index 000000000..c2f8c9bbd --- /dev/null +++ b/apps/desktop/src/main/services/chat/droidSdkProtocol.ts @@ -0,0 +1,119 @@ +export type DroidSdkAutonomyLevel = "off" | "low" | "medium" | "high"; +export type DroidSdkInteractionMode = "auto" | "spec"; +export type DroidSdkReasoningEffort = + | "none" + | "dynamic" + | "off" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh"; + +export type DroidSdkSessionSettings = { + modelId: string; + autonomyLevel: DroidSdkAutonomyLevel; + interactionMode: DroidSdkInteractionMode; + reasoningEffort?: DroidSdkReasoningEffort | null; + specModeModelId?: string | null; + specModeReasoningEffort?: DroidSdkReasoningEffort | null; +}; + +export type DroidSdkWorkerInit = { + sessionId: string; + laneRoot: string; + droidPath: string; + resumeSessionId?: string | null; + settings: DroidSdkSessionSettings; +}; + +export type DroidSdkUserImage = { + data: string; + mimeType: string; +}; + +export type DroidSdkSendPrompt = { + promptText: string; + images?: DroidSdkUserImage[]; + settings: DroidSdkSessionSettings; +}; + +export type DroidSdkReady = { + sessionId: string; + currentModelId: string | null; + availableModels: Array<{ + id: string; + modelId?: string | null; + displayName?: string | null; + shortDisplayName?: string | null; + supportedReasoningEfforts?: string[]; + defaultReasoningEffort?: string | null; + isCustom?: boolean; + }>; +}; + +export type DroidSdkPermissionRequest = { + id: string; + title: string; + summary: string; + toolName: string; + toolInput?: unknown; + toolUseIds: string[]; + options: Array<{ + label: string; + value: string; + }>; + raw: unknown; +}; + +export type DroidSdkPermissionDecision = { + selectedOption: string; + comment?: string; +}; + +export type DroidSdkAskUserRequest = { + id: string; + toolCallId: string; + title: string; + questions: Array<{ + id: string; + header?: string; + question: string; + options?: Array<{ label: string; value: string }>; + }>; + raw: unknown; +}; + +export type DroidSdkAskUserResponse = { + cancelled: boolean; + answers: Array<{ + index: number; + question: string; + answer: string; + }>; +}; + +export type DroidSdkRunResult = { + sessionId: string; + tokenUsage?: unknown; + success: boolean; + error?: unknown; +}; + +export type DroidSdkWorkerRequest = + | { type: "init"; requestId: string; payload: DroidSdkWorkerInit } + | { type: "send"; requestId: string; payload: DroidSdkSendPrompt } + | { type: "settings_update"; requestId: string; payload: DroidSdkSessionSettings } + | { type: "cancel"; requestId: string } + | { type: "dispose"; requestId: string } + | { type: "permission_response"; requestId: string; payload: DroidSdkPermissionDecision } + | { type: "ask_user_response"; requestId: string; payload: DroidSdkAskUserResponse }; + +export type DroidSdkWorkerResponse = + | { type: "response"; requestId: string; ok: true; result?: unknown } + | { type: "response"; requestId: string; ok: false; error: string } + | { type: "ready"; ready: DroidSdkReady } + | { type: "sdk_event"; event: unknown } + | { type: "permission_request"; requestId: string; request: DroidSdkPermissionRequest } + | { type: "ask_user_request"; requestId: string; request: DroidSdkAskUserRequest } + | { type: "log"; level: "debug" | "info" | "warn" | "error"; message: string; detail?: unknown }; diff --git a/apps/desktop/src/main/services/chat/droidSdkWorker.ts b/apps/desktop/src/main/services/chat/droidSdkWorker.ts new file mode 100644 index 000000000..2925ee402 --- /dev/null +++ b/apps/desktop/src/main/services/chat/droidSdkWorker.ts @@ -0,0 +1,318 @@ +import type * as DroidSdkTypes from "@factory/droid-sdk"; +import type { + DroidSdkAskUserRequest, + DroidSdkAskUserResponse, + DroidSdkPermissionDecision, + DroidSdkPermissionRequest, + DroidSdkReady, + DroidSdkReasoningEffort, + DroidSdkSessionSettings, + DroidSdkWorkerInit, + DroidSdkWorkerRequest, + DroidSdkWorkerResponse, +} from "./droidSdkProtocol"; + +type DroidSdkModule = typeof DroidSdkTypes; +type DroidSession = Awaited>; + +let sdkModule: DroidSdkModule | null = null; +let initState: DroidSdkWorkerInit | null = null; +let session: DroidSession | null = null; +let currentAbort: AbortController | null = null; +const permissionWaiters = new Map void>(); +const askUserWaiters = new Map void>(); + +function post(message: DroidSdkWorkerResponse): void { + if (process.send) process.send(message); +} + +function errorMessage(error: unknown): string { + if (!(error instanceof Error)) return String(error); + const message = error.message.trim(); + return message && message !== "Error" ? message : error.name || "Unknown Droid SDK error"; +} + +async function getSdk(): Promise { + if (!sdkModule) sdkModule = await import("@factory/droid-sdk"); + return sdkModule; +} + +function coerceReasoning(value: DroidSdkReasoningEffort | null | undefined): DroidSdkTypes.ReasoningEffort | undefined { + return value?.trim() ? value as DroidSdkTypes.ReasoningEffort : undefined; +} + +function sessionOptions( + sdk: DroidSdkModule, + init: DroidSdkWorkerInit, + settings: DroidSdkSessionSettings, +): DroidSdkTypes.CreateSessionOptions { + return { + cwd: init.laneRoot, + execPath: init.droidPath, + modelId: settings.modelId, + autonomyLevel: settings.autonomyLevel as DroidSdkTypes.AutonomyLevel, + interactionMode: settings.interactionMode === "spec" + ? sdk.DroidInteractionMode.Spec + : sdk.DroidInteractionMode.Auto, + reasoningEffort: coerceReasoning(settings.reasoningEffort), + specModeModelId: settings.specModeModelId?.trim() || undefined, + specModeReasoningEffort: coerceReasoning(settings.specModeReasoningEffort), + permissionHandler: requestPermission, + askUserHandler: requestAskUser, + }; +} + +function summarizePermission(params: DroidSdkTypes.RequestPermissionRequestParams): DroidSdkPermissionRequest { + const toolUses = Array.isArray(params.toolUses) ? params.toolUses : []; + const first = toolUses[0]; + const toolUse = first?.toolUse; + const details = first?.details as Record | undefined; + const toolName = typeof toolUse?.name === "string" && toolUse.name.trim().length + ? toolUse.name.trim() + : typeof details?.type === "string" && details.type.trim().length + ? details.type.trim() + : "tool"; + const title = + typeof details?.title === "string" && details.title.trim().length + ? details.title.trim() + : toolName; + const summary = + typeof details?.fullCommand === "string" && details.fullCommand.trim().length + ? details.fullCommand.trim() + : typeof details?.filePath === "string" && details.filePath.trim().length + ? details.filePath.trim() + : title; + return { + id: toolUse?.id ?? `droid-permission-${Date.now()}`, + title, + summary, + toolName, + toolInput: toolUse?.input, + toolUseIds: toolUses + .map((entry) => entry.toolUse?.id) + .filter((id): id is string => typeof id === "string" && id.length > 0), + options: (params.options ?? []).map((option) => ({ + label: option.label, + value: String(option.value), + })), + raw: params, + }; +} + +async function requestPermission( + params: DroidSdkTypes.RequestPermissionRequestParams, +): Promise { + const request = summarizePermission(params); + const decision = await new Promise((resolve) => { + permissionWaiters.set(request.id, resolve); + post({ type: "permission_request", requestId: request.id, request }); + }); + permissionWaiters.delete(request.id); + return { + selectedOption: decision.selectedOption as DroidSdkTypes.RequestPermissionSelection, + ...(decision.comment?.trim() ? { comment: decision.comment.trim() } : {}), + }; +} + +function summarizeAskUser(params: DroidSdkTypes.AskUserRequestParams): DroidSdkAskUserRequest { + const questions = (params.questions ?? []).map((question, index) => ({ + id: `q_${question.index ?? index + 1}`, + header: question.topic, + question: question.question, + options: question.options?.map((option) => ({ label: option, value: option })), + })); + return { + id: params.toolCallId || `droid-ask-user-${Date.now()}`, + toolCallId: params.toolCallId, + title: questions.length === 1 ? "Question from Droid" : "Questions from Droid", + questions, + raw: params, + }; +} + +async function requestAskUser(params: DroidSdkTypes.AskUserRequestParams): Promise { + const request = summarizeAskUser(params); + const response = await new Promise((resolve) => { + askUserWaiters.set(request.id, resolve); + post({ type: "ask_user_request", requestId: request.id, request }); + }); + askUserWaiters.delete(request.id); + return response as DroidSdkTypes.AskUserResult; +} + +function normalizeAvailableModels(initResult: unknown): DroidSdkReady["availableModels"] { + const record = initResult && typeof initResult === "object" ? initResult as Record : null; + const raw = Array.isArray(record?.availableModels) ? record.availableModels : []; + return raw.flatMap((entry) => { + const model = entry && typeof entry === "object" ? entry as Record : null; + if (!model) return []; + const id = typeof model?.id === "string" ? model.id.trim() : ""; + if (!id.length) return []; + return [{ + id, + modelId: typeof model.modelId === "string" ? model.modelId : null, + displayName: typeof model.displayName === "string" ? model.displayName : null, + shortDisplayName: typeof model.shortDisplayName === "string" ? model.shortDisplayName : null, + supportedReasoningEfforts: Array.isArray(model.supportedReasoningEfforts) + ? model.supportedReasoningEfforts.filter((v): v is string => typeof v === "string") + : undefined, + defaultReasoningEffort: typeof model.defaultReasoningEffort === "string" ? model.defaultReasoningEffort : null, + isCustom: model.isCustom === true, + }]; + }); +} + +function buildReady(): DroidSdkReady { + if (!session) throw new Error("Droid SDK worker is not initialized."); + const initResult = session.initResult as unknown; + const record = initResult && typeof initResult === "object" ? initResult as Record : null; + const currentModelId = typeof record?.currentModelId === "string" ? record.currentModelId : null; + return { + sessionId: session.sessionId, + currentModelId, + availableModels: normalizeAvailableModels(initResult), + }; +} + +async function applySettings(settings: DroidSdkSessionSettings): Promise { + if (!session) throw new Error("Droid SDK worker is not initialized."); + const sdk = await getSdk(); + if (settings.interactionMode === "spec") { + await session.enterSpecMode({ + specModeModelId: settings.specModeModelId?.trim() || settings.modelId, + specModeReasoningEffort: coerceReasoning(settings.specModeReasoningEffort ?? settings.reasoningEffort), + }); + return; + } + await session.updateSettings({ + modelId: settings.modelId, + autonomyLevel: settings.autonomyLevel as DroidSdkTypes.AutonomyLevel, + interactionMode: sdk.DroidInteractionMode.Auto, + reasoningEffort: coerceReasoning(settings.reasoningEffort), + }); +} + +async function initWorker(init: DroidSdkWorkerInit): Promise { + initState = init; + const sdk = await getSdk(); + const resumeId = init.resumeSessionId?.trim(); + if (resumeId) { + try { + session = await sdk.resumeSession(resumeId, { + cwd: init.laneRoot, + execPath: init.droidPath, + permissionHandler: requestPermission, + askUserHandler: requestAskUser, + }); + await applySettings(init.settings); + } catch (error) { + post({ + type: "log", + level: "warn", + message: "Droid SDK resume failed; creating a new session.", + detail: { resumeSessionId: resumeId, error: errorMessage(error) }, + }); + session = await sdk.createSession(sessionOptions(sdk, init, init.settings)); + } + } else { + session = await sdk.createSession(sessionOptions(sdk, init, init.settings)); + } + const ready = buildReady(); + post({ type: "ready", ready }); + return ready; +} + +async function sendPrompt(payload: DroidSdkWorkerRequest & { type: "send" }): Promise { + if (!session || !initState) throw new Error("Droid SDK worker is not initialized."); + await applySettings(payload.payload.settings); + const controller = new AbortController(); + currentAbort = controller; + let tokenUsage: unknown = null; + let firstError: unknown = null; + try { + const images = payload.payload.images?.map((image) => ({ + type: "base64" as const, + data: image.data, + mediaType: image.mimeType as DroidSdkTypes.Base64ImageSource["mediaType"], + })); + for await (const event of session.stream(payload.payload.promptText, { + ...(images?.length ? { images } : {}), + abortSignal: controller.signal, + })) { + if ((event as { type?: string }).type === "token_usage_update") tokenUsage = event; + if ((event as { type?: string }).type === "turn_complete") { + tokenUsage = (event as { tokenUsage?: unknown }).tokenUsage ?? tokenUsage; + } + if ((event as { type?: string }).type === "error" && firstError == null) firstError = event; + post({ type: "sdk_event", event }); + } + return { + sessionId: session.sessionId, + tokenUsage, + success: firstError == null, + ...(firstError ? { error: firstError } : {}), + }; + } finally { + if (currentAbort === controller) currentAbort = null; + } +} + +async function cancelRun(): Promise { + for (const [, resolve] of permissionWaiters) resolve({ selectedOption: "cancel" }); + permissionWaiters.clear(); + for (const [, resolve] of askUserWaiters) resolve({ cancelled: true, answers: [] }); + askUserWaiters.clear(); + currentAbort?.abort(); + await session?.interrupt().catch(() => undefined); +} + +async function dispose(): Promise { + await cancelRun().catch(() => undefined); + await session?.close().catch(() => undefined); + session = null; + initState = null; +} + +async function dispatch(req: DroidSdkWorkerRequest): Promise { + switch (req.type) { + case "init": + return initWorker(req.payload); + case "send": + return sendPrompt(req); + case "settings_update": + await applySettings(req.payload); + return buildReady(); + case "cancel": + await cancelRun(); + return {}; + case "dispose": + await dispose(); + return {}; + case "permission_response": { + permissionWaiters.get(req.requestId)?.(req.payload); + return {}; + } + case "ask_user_response": { + askUserWaiters.get(req.requestId)?.(req.payload); + return {}; + } + default: + throw new Error(`Unsupported Droid SDK worker request ${(req as { type?: string }).type}`); + } +} + +process.on("message", (raw: unknown) => { + const req = raw as DroidSdkWorkerRequest; + if (!req || typeof req !== "object" || typeof req.requestId !== "string") return; + void dispatch(req) + .then((result) => { + post({ type: "response", requestId: req.requestId, ok: true, result }); + }) + .catch((error) => { + post({ type: "response", requestId: req.requestId, ok: false, error: errorMessage(error) }); + }); +}); + +process.once("disconnect", () => { + void dispose().finally(() => process.exit(0)); +}); diff --git a/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx b/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx index a86f1a493..094a90606 100644 --- a/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx +++ b/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx @@ -13,7 +13,7 @@ import { Sparkle, X, } from "@phosphor-icons/react"; -import { ProviderModelSelector } from "../shared/ProviderModelSelector"; +import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; import { useAppStore } from "../../state/appStore"; import { COLORS, MONO_FONT, SANS_FONT } from "../lanes/laneDesignTokens"; import type { AppInfo, ProjectInfo } from "../../../shared/types/core"; @@ -647,13 +647,14 @@ function NewReportTab({
AI assist (optional) - { setModelId(id); setReasoningEffort(null); clearPreparedDraft(); }} + surfaceKey="feedback-reporter" availableModelIds={availableModelIds} showReasoning reasoningEffort={reasoningEffort} @@ -661,7 +662,6 @@ function NewReportTab({ setReasoningEffort(value); clearPreparedDraft(); }} - onOpenAiSettings={openAiProvidersSettings} /> {helperText("Leave this empty to build a fully deterministic draft. If you pick a model, ADE only uses it to suggest the title and labels.")}
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 972af9457..fa15c9b47 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -35,7 +35,7 @@ import { } from "../../../shared/chatContextAttachments"; import { getModelById, modelSupportsFastMode } from "../../../shared/modelRegistry"; import { cn } from "../ui/cn"; -import { ProviderModelSelector } from "../shared/ProviderModelSelector"; +import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; import { getPermissionOptions, safetyColors } from "../shared/permissionOptions"; import { CodexTokenInline } from "./codex/CodexTokenInline"; import { ChatAttachmentTray, type ChatAttachmentPendingImage } from "./ChatAttachmentTray"; @@ -459,6 +459,20 @@ function resolveCodexPermissionPreset(args: { return "custom"; } +function safetyDotClass(safety: "safe" | "semi-auto" | "full-auto" | "danger" | "custom"): string { + switch (safety) { + case "safe": + return "bg-emerald-400/80"; + case "semi-auto": + return "bg-amber-400/80"; + case "full-auto": + case "danger": + return "bg-red-400/80"; + case "custom": + return "bg-violet-400/80"; + } +} + const OPENCODE_PERMISSION_OPTIONS: Array<{ value: AgentChatOpenCodePermissionMode; label: string }> = [ { value: "plan", label: "Plan" }, { value: "edit", label: "Edit" }, @@ -1967,6 +1981,7 @@ export function AgentChatComposer({
{claudeModePickerOpen && claudeModePickerRef.current ? createPortal( (() => { @@ -2005,7 +2018,7 @@ export function AgentChatComposer({ role="listbox" aria-label="Claude permission mode" data-claude-mode-picker-dropdown - className="fixed z-[80] w-56 overflow-hidden rounded-lg border border-white/[0.08] bg-[#15151c] shadow-lg shadow-black/40" + className="fixed z-[100] w-56 overflow-hidden rounded-xl border border-white/[0.08] bg-[#13111A]/95 shadow-[0_18px_48px_rgba(0,0,0,0.55)] backdrop-blur-md" style={{ left: rect.left, bottom: window.innerHeight - rect.top + 8, @@ -2063,11 +2076,11 @@ export function AgentChatComposer({ const presetLabel = codexPreset === "custom" ? "Custom" : activePreset?.label ?? "Plan"; - const activeColors = activePreset ? safetyColors(activePreset.safety) : null; return (
{codexPresetPickerOpen && codexPresetPickerRef.current ? createPortal( (() => { @@ -2102,7 +2122,7 @@ export function AgentChatComposer({ role="listbox" aria-label="Codex approval preset" data-codex-preset-picker-dropdown - className="fixed z-[80] w-56 overflow-hidden rounded-lg border border-white/[0.08] bg-[#15151c] shadow-lg shadow-black/40" + className="fixed z-[100] w-56 overflow-hidden rounded-xl border border-white/[0.08] bg-[#13111A]/95 shadow-[0_18px_48px_rgba(0,0,0,0.55)] backdrop-blur-md" style={{ left: rect.left, bottom: window.innerHeight - rect.top + 8, @@ -3383,17 +3403,16 @@ export function AgentChatComposer({
) : null} {parallelChatMode && parallelConfiguringIndex != null && parallelModelSlots[parallelConfiguringIndex] ? ( - onParallelSlotModelChange?.(parallelConfiguringIndex, next)} - onOpen={onModelCatalogOpen} - availableModelIds={availableModelIds} + surfaceKey={`chat-composer-parallel-${parallelConfiguringIndex}`} + {...(availableModelIds ? { availableModelIds } : {})} disabled={parallelLaunchBusy} showReasoning reasoningEffort={parallelModelSlots[parallelConfiguringIndex]!.reasoningEffort} onReasoningEffortChange={(effort) => onParallelSlotReasoningChange?.(parallelConfiguringIndex, effort)} - onOpenAiSettings={onOpenAiSettings} - compactToolbar + compact /> ) : null} {parallelChatMode && parallelConfiguringIndex != null && fastModeSupported ? ( @@ -3404,17 +3423,16 @@ export function AgentChatComposer({ /> ) : null} {!parallelChatMode ? ( - ) : null} {!parallelChatMode && fastModeSupported ? ( diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 2b10908e6..8cc67534f 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -100,7 +100,7 @@ import { ChatGitToolbar } from "./ChatGitToolbar"; import { ChatTerminalDrawer, ChatTerminalToggle } from "./ChatTerminalDrawer"; import { deriveChatSubagentSnapshots, deriveTodoItems, deriveTurnDiffSummaries } from "./chatExecutionSummary"; import { derivePendingInputRequests, type DerivedPendingInput } from "./pendingInput"; -import { ProviderModelSelector } from "../shared/ProviderModelSelector"; +import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; import { useClickOutside } from "../../hooks/useClickOutside"; import { useAppStore } from "../../state/appStore"; @@ -6054,15 +6054,14 @@ export function AgentChatPane({ ) : null}
-
{handoffTargetProvider ? ( diff --git a/apps/desktop/src/renderer/components/cto/IdentityEditor.tsx b/apps/desktop/src/renderer/components/cto/IdentityEditor.tsx index e9e6eff39..40e935b2e 100644 --- a/apps/desktop/src/renderer/components/cto/IdentityEditor.tsx +++ b/apps/desktop/src/renderer/components/cto/IdentityEditor.tsx @@ -6,7 +6,7 @@ import { deriveConfiguredModelIds } from "../../lib/modelOptions"; import { Button } from "../ui/Button"; import { cn } from "../ui/cn"; import { cardCls, labelCls, recessedPanelCls, textareaCls } from "./shared/designTokens"; -import { ProviderModelSelector } from "../shared/ProviderModelSelector"; +import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; import { CTO_PERSONALITY_PRESETS, getCtoPersonalityPreset } from "./identityPresets"; import { CtoPromptPreview } from "./CtoPromptPreview"; @@ -167,9 +167,10 @@ export function IdentityEditor({
Model
- setDraft((current) => ({ @@ -180,7 +181,6 @@ export function IdentityEditor({ setDraft((current) => applyModelSelection(current, modelId)); setError(null); }} - onOpenAiSettings={openAiProvidersSettings} /> {loadingModels ? (
Checking configured models...
diff --git a/apps/desktop/src/renderer/components/cto/WorkerCreationWizard.tsx b/apps/desktop/src/renderer/components/cto/WorkerCreationWizard.tsx index 9a6c4e355..4c4e42bb0 100644 --- a/apps/desktop/src/renderer/components/cto/WorkerCreationWizard.tsx +++ b/apps/desktop/src/renderer/components/cto/WorkerCreationWizard.tsx @@ -9,7 +9,7 @@ import { Wrench, } from "@phosphor-icons/react"; import type { AgentIdentity, AgentRole, WorkerTemplate } from "../../../shared/types"; -import { ProviderModelSelector } from "../shared/ProviderModelSelector"; +import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; import { Button } from "../ui/Button"; import { cn } from "../ui/cn"; import { SmartTooltip } from "../ui/SmartTooltip"; @@ -198,9 +198,10 @@ export function WorkerCreationWizard({
Model
- setDraft((d) => ({ ...d, model: modelId }))} + surfaceKey="cto-worker-creation" />
diff --git a/apps/desktop/src/renderer/components/missions/ModelSelector.tsx b/apps/desktop/src/renderer/components/missions/ModelSelector.tsx index 2d6bf8d98..044192629 100644 --- a/apps/desktop/src/renderer/components/missions/ModelSelector.tsx +++ b/apps/desktop/src/renderer/components/missions/ModelSelector.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useMemo } from "react"; import type { ModelConfig, ModelProvider, ThinkingLevel } from "../../../shared/types"; import { getModelById, resolveModelDescriptor } from "../../../shared/modelRegistry"; -import { ProviderModelSelector } from "../shared/ProviderModelSelector"; +import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; type ModelSelectorProps = { value: ModelConfig; @@ -11,6 +11,8 @@ type ModelSelectorProps = { /** When provided, only models whose registry id is in this set are shown. */ availableModelIds?: string[]; onOpenAiSettings?: () => void; + /** Stable id used by the picker to remember per-surface defaults. */ + surfaceKey?: string; }; function providerFromFamily(modelId: string): ModelProvider | undefined { @@ -37,7 +39,8 @@ export function ModelSelector({ compact, showRecommendedBadge: _showRecommendedBadge, availableModelIds, - onOpenAiSettings, + onOpenAiSettings: _onOpenAiSettings, + surfaceKey = "missions/phase-or-action", }: ModelSelectorProps) { const resolvedModelId = useMemo(() => normalizeModelId(value.modelId), [value.modelId]); const selectedDescriptor = useMemo(() => getModelById(resolvedModelId), [resolvedModelId]); @@ -62,15 +65,15 @@ export function ModelSelector({ }, [onChange, resolvedModelId]); return ( - ); } diff --git a/apps/desktop/src/renderer/components/prs/shared/PrResolverLaunchControls.test.tsx b/apps/desktop/src/renderer/components/prs/shared/PrResolverLaunchControls.test.tsx index c83d5a69b..fba402e00 100644 --- a/apps/desktop/src/renderer/components/prs/shared/PrResolverLaunchControls.test.tsx +++ b/apps/desktop/src/renderer/components/prs/shared/PrResolverLaunchControls.test.tsx @@ -6,13 +6,13 @@ import { cleanup, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PrAgentPermissionMode } from "../../../../shared/types"; -import type { ProviderModelSelector } from "../../shared/ProviderModelSelector"; +import type { ModelPicker } from "../../shared/ModelPicker/ModelPicker"; import { PrResolverLaunchControls } from "./PrResolverLaunchControls"; -type ProviderModelSelectorProps = React.ComponentProps; +type ModelPickerProps = React.ComponentProps; -vi.mock("../../shared/ProviderModelSelector", () => ({ - ProviderModelSelector: (props: ProviderModelSelectorProps) => ( +vi.mock("../../shared/ModelPicker/ModelPicker", () => ({ + ModelPicker: (props: ModelPickerProps) => (
diff --git a/apps/desktop/src/renderer/components/shared/ModelCatalogPanel.tsx b/apps/desktop/src/renderer/components/shared/ModelCatalogPanel.tsx deleted file mode 100644 index 3c64bf290..000000000 --- a/apps/desktop/src/renderer/components/shared/ModelCatalogPanel.tsx +++ /dev/null @@ -1,886 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { - createDynamicDroidCliModelDescriptor, - createDynamicOpenCodeModelDescriptor, - LOCAL_PROVIDER_LABELS, - MODEL_REGISTRY, - getLocalModelIdTail, - parseDynamicDroidModelRef, - parseDynamicOpenCodeModelRef, - parseLocalProviderFromModelId, - resolveModelDescriptor, - type ModelDescriptor, -} from "../../../shared/modelRegistry"; -import { cn } from "../ui/cn"; -import { Check, Cpu, MagnifyingGlass } from "@phosphor-icons/react"; -import { Claude, Codex, Cursor, OpenCode } from "@lobehub/icons"; -import { ModelRowLogo, ProviderLogo } from "./ProviderLogos"; -import { - buildProviderGroupBlocks, - classifyProviderGroup, - createModelOrderMap, - matchesQuery, - PROVIDER_BADGE_COLORS, - PROVIDER_CATEGORY_LABELS, - PROVIDER_GROUP_COLORS, - providerGroupLabel, - providerLabel, - sortOpenCodeProvidersByCategory, - subsectionKeyForModel, - type ModelProviderBlock, - type ModelProviderGroupBlock, - type ModelSubsection, - type ProviderCategory, - type ProviderGroupKey, -} from "./providerModelSelectorGrouping"; - -const GROUP_KEYS: ProviderGroupKey[] = ["claude", "codex", "cursor", "droid", "opencode"]; - -function catalogGroupTabIcon(key: ProviderGroupKey, size = 15) { - const c = "shrink-0 inline-flex [&_svg]:max-h-none [&_svg]:max-w-none opacity-90"; - switch (key) { - case "claude": - return ; - case "codex": - return ; - case "cursor": - return ; - case "droid": - return ; - case "opencode": - return ; - default: - return null; - } -} - -function rgbaFromHex(hex: string, alpha: number): string { - const n = hex.replace("#", "").trim(); - if (n.length !== 6) return `rgba(167,139,250,${alpha})`; - const r = Number.parseInt(n.slice(0, 2), 16); - const g = Number.parseInt(n.slice(2, 4), 16); - const b = Number.parseInt(n.slice(4, 6), 16); - return `rgba(${r},${g},${b},${alpha})`; -} - -function providerAccent(family: string, fallback?: string): string { - return PROVIDER_BADGE_COLORS[family] ?? fallback ?? "#A78BFA"; -} - -function subsectionTabTitle(sub: ModelSubsection): string { - return sub.label.trim() || "Models"; -} - -function modelAvailabilityLabel(model: ModelDescriptor, isAvailable: boolean): string { - if (isAvailable) { - if (model.family === "cursor") return "Cursor SDK ready"; - if (model.isCliWrapped && model.cliCommand === "claude") return "Claude ready"; - if (model.isCliWrapped && model.cliCommand === "codex") return "Codex ready"; - if (model.authTypes.includes("local")) return `${providerLabel(model.family)} ready`; - if (model.authTypes.includes("api-key")) return "OpenCode · API ready"; - if (model.authTypes.includes("oauth")) return "OpenCode ready"; - if (model.authTypes.includes("openrouter")) return "OpenCode · OpenRouter ready"; - return "Ready"; - } - if (model.family === "cursor") { - return "Cursor · enter a Cursor API key"; - } - if (model.isCliWrapped && model.cliCommand === "claude") return "Claude · not configured"; - if (model.isCliWrapped && model.cliCommand === "codex") return "Codex · not configured"; - if (model.isCliWrapped) return "CLI · not configured"; - if (model.authTypes.includes("local")) return `OpenCode · ${providerLabel(model.family)} not configured`; - if (model.authTypes.includes("api-key")) return "OpenCode · API key not configured"; - if (model.authTypes.includes("oauth")) return "OpenCode · not configured"; - if (model.authTypes.includes("openrouter")) return "OpenCode · OpenRouter not configured"; - return "Not configured"; -} - -export function createUnknownModelPlaceholder(modelId: string): ModelDescriptor { - console.warn(`[ModelCatalogPanel] Unknown model ID "${modelId}" — not found in registry. Creating placeholder.`); - const openCode = parseDynamicOpenCodeModelRef(modelId); - if (openCode) { - return createDynamicOpenCodeModelDescriptor(openCode.modelId); - } - const cursorCli = modelId.startsWith("cursor/"); - if (cursorCli) { - const tail = modelId.slice("cursor/".length); - return { - id: modelId, - shortId: tail || modelId, - displayName: tail || modelId, - family: "cursor", - authTypes: ["api-key"], - contextWindow: 0, - maxOutputTokens: 0, - capabilities: { tools: true, vision: false, reasoning: false, streaming: true }, - color: "#A78BFA", - providerRoute: "cursor-sdk", - providerModelId: tail || modelId, - cliCommand: "cursor", - isCliWrapped: false, - }; - } - const droidCli = parseDynamicDroidModelRef(modelId); - if (droidCli) { - return createDynamicDroidCliModelDescriptor(droidCli.providerModelId); - } - const localProvider = parseLocalProviderFromModelId(modelId); - if (localProvider) { - const shortId = getLocalModelIdTail(modelId, localProvider) || modelId; - const brand = LOCAL_PROVIDER_LABELS[localProvider]; - return { - id: modelId, - shortId, - displayName: shortId, - family: localProvider, - authTypes: ["local"], - contextWindow: 0, - maxOutputTokens: 0, - capabilities: { tools: false, vision: false, reasoning: false, streaming: true }, - color: PROVIDER_BADGE_COLORS[localProvider] ?? "#64748B", - providerRoute: "openai-compatible", - providerModelId: shortId, - isCliWrapped: false, - discoverySource: localProvider === "lmstudio" ? "lmstudio-openai" : localProvider, - harnessProfile: "guarded", - aliases: brand ? [brand] : [], - }; - } - return { - id: modelId, - shortId: modelId, - displayName: modelId, - family: "openrouter", - authTypes: ["api-key"], - contextWindow: 0, - maxOutputTokens: 0, - capabilities: { tools: false, vision: false, reasoning: false, streaming: false }, - color: "#6B7280", - providerRoute: "unknown", - providerModelId: modelId, - isCliWrapped: false, - }; -} - -export function mergeSelectorModels( - availableModelIds?: string[], - selectedModelId?: string, - filter?: (model: ModelDescriptor) => boolean, - catalogMode: "all" | "available-only" = "all", -): ModelDescriptor[] { - const merged = new Map(); - const selectedId = String(selectedModelId ?? "").trim(); - const availableIdSet = new Set( - (availableModelIds ?? []) - .map((entry) => String(entry ?? "").trim()) - .filter(Boolean), - ); - if (catalogMode === "all") { - for (const model of MODEL_REGISTRY) { - if (model.deprecated) continue; - if (filter && !filter(model)) continue; - merged.set(model.id, model); - } - } - - for (const rawId of availableIdSet) { - const descriptor = resolveModelDescriptor(rawId); - if (descriptor) { - if (descriptor.deprecated) continue; - if (filter && !filter(descriptor)) continue; - merged.set(descriptor.id, descriptor); - } else { - const placeholder = createUnknownModelPlaceholder(rawId); - if (filter && !filter(placeholder)) continue; - merged.set(placeholder.id, placeholder); - } - } - - if (selectedId && !merged.has(selectedId)) { - const selectedDescriptor = resolveModelDescriptor(selectedId); - if (selectedDescriptor && !selectedDescriptor.deprecated && (!filter || filter(selectedDescriptor))) { - merged.set(selectedDescriptor.id, selectedDescriptor); - } else if (!selectedDescriptor) { - const placeholder = createUnknownModelPlaceholder(selectedId); - if (!filter || filter(placeholder)) { - merged.set(placeholder.id, placeholder); - } - } - } - return [...merged.values()]; -} - -function flattenGroupBlocks(blocks: ModelProviderGroupBlock[]): ModelDescriptor[] { - return blocks.flatMap((g) => g.providers.flatMap((p) => p.subsections.flatMap((sub) => sub.models))); -} - -function OpenCodeCategorizedBadges({ - providers, - activeProviderKey, - onSelect, -}: { - providers: ModelProviderBlock[]; - activeProviderKey?: string; - onSelect: (key: string) => void; -}) { - const { cloud, local, router } = sortOpenCodeProvidersByCategory(providers); - const categories: { key: ProviderCategory; items: ModelProviderBlock[] }[] = [ - { key: "cloud-api", items: cloud }, - { key: "local", items: local }, - { key: "router", items: router }, - ]; - const shouldBoundHeight = providers.length > 10; - - return ( -
- {categories.map(({ key: catKey, items }) => { - if (!items.length) return null; - return ( -
- - {PROVIDER_CATEGORY_LABELS[catKey]} - -
- {items.map((prov) => { - const isProvActive = activeProviderKey === prov.key; - const fill = providerAccent(prov.key, prov.badgeColor); - const hasModels = prov.modelCount > 0; - return ( - - ); - })} -
-
- ); - })} -
- ); -} - -export type ModelCatalogPanelProps = { - value?: string; - availableModelIds?: string[]; - catalogMode?: "all" | "available-only"; - filter?: (model: ModelDescriptor) => boolean; - onOpenAiSettings?: () => void; - /** When set, user can activate an available model (e.g. chat picker). Omit in settings browse mode. */ - onSelectModel?: (modelId: string) => void; - className?: string; - listboxId?: string; - /** Extra control in the search row (e.g. modal close). */ - headerTrailing?: ReactNode; - /** Do not render footer link to AI settings. */ - hideOpenSettingsFooter?: boolean; - autoFocusSearch?: boolean; - /** When false, reset focus/tab init when re-enabled (modal closed). Default true. */ - enabled?: boolean; -}; - -/** - * Shared catalog UI: same grouping, search, and rows as the Work chat model picker. - * Use in Settings (embedded) and inside ProviderModelSelector (modal). - */ -export function ModelCatalogPanel({ - value = "", - availableModelIds, - catalogMode = "all", - filter, - onOpenAiSettings, - onSelectModel, - className, - listboxId = "model-catalog-listbox", - headerTrailing, - hideOpenSettingsFooter = false, - autoFocusSearch = false, - enabled = true, -}: ModelCatalogPanelProps) { - const panelRef = useRef(null); - const pickerInitRef = useRef(false); - - // Fetch dynamic provider list from OpenCode so all providers show as badges - const [opencodeProviders, setOpencodeProviders] = useState>([]); - useEffect(() => { - let cancelled = false; - window.ade?.ai?.getStatus?.().then((status: any) => { - if (cancelled) return; - if (Array.isArray(status?.opencodeProviders)) { - setOpencodeProviders(status.opencodeProviders); - } - }).catch(() => { /* ignore — fallback list used */ }); - return () => { cancelled = true; }; - }, [availableModelIds]); - - const availableKey = useMemo(() => (availableModelIds ?? []).join("\0"), [availableModelIds]); - - useEffect(() => { - if (!enabled) { - pickerInitRef.current = false; - } - }, [enabled]); - - useEffect(() => { - pickerInitRef.current = false; - }, [availableKey]); - - const [query, setQuery] = useState(""); - const [activeGroup, setActiveGroup] = useState("claude"); - const [activeProvider, setActiveProvider] = useState("anthropic"); - const [activeSubsection, setActiveSubsection] = useState(""); - const [focusedIndex, setFocusedIndex] = useState(-1); - - const availableSet = useMemo( - () => (availableModelIds ? new Set(availableModelIds.map((entry) => String(entry ?? "").trim()).filter(Boolean)) : null), - [availableModelIds], - ); - const modelOrder = useMemo(() => createModelOrderMap(), []); - const selectorModels = useMemo( - () => mergeSelectorModels(availableModelIds, value, filter, catalogMode), - [availableModelIds, catalogMode, filter, value], - ); - - const fullTree = useMemo( - () => buildProviderGroupBlocks(selectorModels, modelOrder, opencodeProviders, catalogMode !== "available-only"), - [selectorModels, modelOrder, opencodeProviders, catalogMode], - ); - - const isSearchMode = query.trim().length > 0; - const searchTree = useMemo(() => { - const filtered = selectorModels.filter((m) => matchesQuery(m, query)); - return buildProviderGroupBlocks(filtered, modelOrder, opencodeProviders, catalogMode !== "available-only"); - }, [selectorModels, query, modelOrder, opencodeProviders, catalogMode]); - - const groupModelCounts = useMemo(() => { - const map = new Map(); - for (const block of fullTree) { - const n = block.providers.reduce((acc, p) => acc + p.modelCount, 0); - map.set(block.key, n); - } - return map; - }, [fullTree]); - - const providersInActiveGroup = useMemo(() => { - return fullTree.find((g) => g.key === activeGroup)?.providers ?? []; - }, [fullTree, activeGroup]); - - const activeProviderBlock: ModelProviderBlock | null = useMemo(() => { - if (!providersInActiveGroup.length) return null; - return providersInActiveGroup.find((p) => p.key === activeProvider) ?? providersInActiveGroup[0] ?? null; - }, [providersInActiveGroup, activeProvider]); - - const flatModels = useMemo(() => { - if (isSearchMode) return flattenGroupBlocks(searchTree); - if (!activeProviderBlock) return []; - const sub = - activeProviderBlock.subsections.find((s) => s.key === activeSubsection) ?? activeProviderBlock.subsections[0]; - return sub?.models ?? []; - }, [isSearchMode, searchTree, activeProviderBlock, activeSubsection]); - - const selectedModel = useMemo( - () => resolveModelDescriptor(value) ?? (value ? createUnknownModelPlaceholder(value) : undefined), - [value], - ); - - const selectedGroup = selectedModel ? classifyProviderGroup(selectedModel) : null; - const selectedProviderKey = selectedModel?.family; - - useEffect(() => { - if (!enabled) return; - if (fullTree.length === 0) return; - if (pickerInitRef.current) return; - pickerInitRef.current = true; - if (selectedModel && selectedGroup && selectedProviderKey) { - const hasGroup = fullTree.some((b) => b.key === selectedGroup); - const groupKey = hasGroup ? selectedGroup : fullTree[0]!.key; - setActiveGroup(groupKey); - const provs = fullTree.find((b) => b.key === groupKey)?.providers ?? []; - const hasProv = provs.some((p) => p.key === selectedProviderKey); - const nextProvKey = hasProv ? selectedProviderKey : provs[0]?.key ?? "anthropic"; - setActiveProvider(nextProvKey); - const provBlock = provs.find((p) => p.key === nextProvKey) ?? provs[0]; - if (provBlock) { - const sk = subsectionKeyForModel(selectedModel, groupKey); - setActiveSubsection(provBlock.subsections.some((s) => s.key === sk) ? sk : provBlock.subsections[0]?.key ?? ""); - } - } else if (fullTree[0]) { - setActiveGroup(fullTree[0].key); - const p0 = fullTree[0].providers[0]; - setActiveProvider(p0?.key ?? "anthropic"); - setActiveSubsection(p0?.subsections[0]?.key ?? ""); - } - }, [enabled, selectedModel, selectedGroup, selectedProviderKey, fullTree]); - - useEffect(() => { - if (!providersInActiveGroup.some((p) => p.key === activeProvider) && providersInActiveGroup[0]) { - setActiveProvider(providersInActiveGroup[0].key); - } - }, [activeGroup, providersInActiveGroup, activeProvider]); - - useEffect(() => { - if (!activeProviderBlock) return; - const keys = activeProviderBlock.subsections.map((s) => s.key); - if (!keys.includes(activeSubsection)) { - setActiveSubsection(keys[0] ?? ""); - } - }, [activeProviderBlock, activeSubsection]); - - useEffect(() => { - setFocusedIndex(-1); - }, [activeGroup, activeProvider, activeSubsection, query, isSearchMode]); - - const openSettings = onOpenAiSettings; - - const handleSelect = useCallback( - (modelId: string, isAvailable: boolean) => { - if (!onSelectModel || !isAvailable) return; - onSelectModel(modelId); - }, - [onSelectModel], - ); - - const handleListKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (!flatModels.length) return; - let nextIndex = focusedIndex; - - switch (event.key) { - case "ArrowDown": - event.preventDefault(); - nextIndex = focusedIndex < flatModels.length - 1 ? focusedIndex + 1 : 0; - break; - case "ArrowUp": - event.preventDefault(); - nextIndex = focusedIndex > 0 ? focusedIndex - 1 : flatModels.length - 1; - break; - case "Home": - event.preventDefault(); - nextIndex = 0; - break; - case "End": - event.preventDefault(); - nextIndex = flatModels.length - 1; - break; - case "Enter": { - event.preventDefault(); - if (focusedIndex >= 0 && focusedIndex < flatModels.length && onSelectModel) { - const model = flatModels[focusedIndex]; - const isAvailable = !availableSet || availableSet.has(model.id); - handleSelect(model.id, isAvailable); - } - return; - } - default: - return; - } - setFocusedIndex(nextIndex); - - const panel = panelRef.current; - if (panel) { - const options = panel.querySelectorAll("[data-model-option='true']"); - options[nextIndex]?.scrollIntoView({ block: "nearest" }); - } - }, - [flatModels, focusedIndex, availableSet, handleSelect, onSelectModel], - ); - - const stripAccentColor = PROVIDER_GROUP_COLORS[activeGroup] ?? (activeProviderBlock - ? providerAccent(activeProviderBlock.key, activeProviderBlock.badgeColor) - : "var(--color-accent)"); - - const selectable = Boolean(onSelectModel); - - const renderModelRow = (model: ModelDescriptor, keyPrefix: string) => { - const isSelected = model.id === selectedModel?.id; - const isAvailable = !availableSet || availableSet.has(model.id); - const isFocused = focusedIndex >= 0 && flatModels[focusedIndex]?.id === model.id; - const isUnknown = model.providerRoute === "unknown"; - const accent = providerAccent(model.family, model.color); - const borderLeft = `3px solid ${accent}`; - const bgSelected = rgbaFromHex(accent, 0.1); - const bgFocused = isFocused && (selectable ? isAvailable : true) ? rgbaFromHex(accent, 0.08) : undefined; - - const rowClass = cn( - "mx-2 flex w-[calc(100%-16px)] flex-col gap-1 rounded-xl border px-4 py-3 text-left font-sans text-[13px] transition-all duration-150", - isSelected - ? "border-violet-400/20 bg-gradient-to-br from-violet-500/[0.08] to-violet-500/[0.03]" - : "border-white/[0.06] bg-white/[0.03]", - isAvailable ? "text-fg/90" : "text-muted-fg/22", - selectable && - isAvailable && - !isSelected && - "hover:bg-white/[0.05] hover:border-white/[0.08]", - ); - - const rowStyle: React.CSSProperties & { "--model-row-accent"?: string } = { - "--model-row-accent": accent, - backgroundColor: isSelected - ? undefined - : isFocused && (selectable ? isAvailable : true) - ? bgFocused - : isFocused && selectable && !isAvailable - ? "rgba(255,255,255,0.02)" - : undefined, - }; - - const inner = ( - <> -
- - - -
-
-
{model.displayName}
- {isSelected ? ( - - active - - ) : null} - {isUnknown ? ( - - Unknown - - ) : null} - {model.customProxy ? ( - - Proxy - - ) : null} -
-
- {modelAvailabilityLabel(model, isAvailable)} -
-
-
- {isAvailable && !isSelected ? ( - - - Ready - - ) : null} - {isSelected ? : } -
-
- {!isAvailable && openSettings ? ( - - ) : null} - - ); - - if (selectable && isAvailable) { - return ( - - ); - } - - return ( -
{ - const idx = flatModels.findIndex((m) => m.id === model.id); - if (idx >= 0) setFocusedIndex(idx); - }} - > - {inner} -
- ); - }; - - const listContent = (() => { - if (isSearchMode) { - if (!searchTree.length) { - return
No models match this search.
; - } - return searchTree.map((groupBlock) => ( -
-
-
- - {groupBlock.label} -
-
- {groupBlock.providers.map((prov) => ( -
- {groupBlock.key === "opencode" ? ( -
- - {prov.label} - {prov.modelCount} models -
- ) : null} - {prov.subsections.map((sub) => ( -
- {sub.label ? ( -
- {subsectionTabTitle(sub)} -
- ) : null} -
{sub.models.map((m) => renderModelRow(m, `${groupBlock.key}:${prov.key}`))}
-
- ))} -
- ))} -
- )); - } - - if (!activeProviderBlock || !flatModels.length) { - if (activeGroup === "opencode") { - const providerName = activeProviderBlock?.label ?? "this provider"; - const providerKey = activeProviderBlock?.key; - const isLocal = providerKey && ["ollama", "lmstudio"].includes(providerKey); - const isFree = providerKey === "opencode"; - const isKnownApiProvider = providerKey && ["anthropic", "openai", "google", "mistral", "deepseek", "xai", "groq", "together", "openrouter"].includes(providerKey); - return ( -
-
- {activeProviderBlock ? `No ${providerName} models discovered` : "No models discovered yet"} -
-
- {isFree - ? "Free models from OpenCode. Hit Refresh in Settings > Providers to discover them." - : isLocal - ? `Start ${providerName} and load a model, then refresh.` - : isKnownApiProvider - ? `Add your ${providerName} API key in Settings > Providers to unlock models.` - : activeProviderBlock - ? `This provider requires configuration in OpenCode. Run "opencode config" to set it up.` - : "Add API keys or connect local runtimes in Settings to unlock models."} -
- {openSettings ? ( - - ) : null} -
- ); - } - if (!activeProviderBlock) { - return ( -
- No models in this category. Try another source or search. -
- ); - } - return
No models in this group.
; - } - - return
{flatModels.map((m) => renderModelRow(m, activeProviderBlock.key))}
; - })(); - - return ( -
- -
-
-
- - Select Model -
-
-
-
- - setQuery(event.target.value)} - onKeyDown={handleListKeyDown} - placeholder="Search models…" - aria-label="Search models" - className="min-w-0 w-[160px] bg-transparent font-sans text-[12px] text-fg/90 outline-none placeholder:text-muted-fg/30" - autoFocus={autoFocusSearch} - role="combobox" - aria-controls={listboxId} - aria-expanded={true} - aria-activedescendant={ - focusedIndex >= 0 && flatModels[focusedIndex] ? `model-option-${flatModels[focusedIndex].id}` : undefined - } - /> -
-
- {headerTrailing ?
{headerTrailing}
: null} -
-
- -
- {GROUP_KEYS.map((key) => { - const count = groupModelCounts.get(key) ?? 0; - const segActive = activeGroup === key && !isSearchMode; - const empty = count === 0 && key !== "opencode"; - return ( - - ); - })} -
- - {!isSearchMode ? ( - activeGroup === "opencode" && providersInActiveGroup.length > 0 ? ( - - ) : (activeGroup === "claude" || activeGroup === "codex") ? null - : ( -
- {providersInActiveGroup.map((prov) => { - const isProvActive = activeProviderBlock?.key === prov.key; - return ( - - ); - })} -
- ) - ) : ( -
Search results (all sources)
- )} - - {!isSearchMode && activeProviderBlock && activeProviderBlock.subsections.length > 1 ? ( -
- {activeProviderBlock.subsections.map((sub) => { - const tabActive = activeSubsection === sub.key; - const count = sub.models.length; - return ( - - ); - })} -
- ) : null} -
- -
- {listContent} -
- - {!hideOpenSettingsFooter && openSettings ? ( -
- -
- ) : null} -
- ); -} diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelListRow.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelListRow.tsx new file mode 100644 index 000000000..0f150a2be --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelListRow.tsx @@ -0,0 +1,311 @@ +import { memo, useCallback } from "react"; +import * as ContextMenu from "@radix-ui/react-context-menu"; +import { Star, Lightning } from "@phosphor-icons/react"; +import type { ModelDescriptor } from "../../../../shared/modelRegistry"; +import { ModelRowLogo } from "../ProviderLogos"; +import { cn } from "../../ui/cn"; + +const LOCAL_FAMILIES = new Set(["ollama", "lmstudio"]); + +function isLocalModel(model: ModelDescriptor): boolean { + return LOCAL_FAMILIES.has(model.family) || model.authTypes.includes("local"); +} + +function subProviderLabel(model: ModelDescriptor): string | null { + const sub = (model as ModelDescriptor & { subProvider?: string }).subProvider; + if (typeof sub === "string" && sub.trim().length) return sub.trim(); + if (model.providerRoute === "opencode" && model.openCodeProviderId) { + return `${model.openCodeProviderId} via OpenCode`; + } + return null; +} + +export type InlineReasoningChipState = { + visible: boolean; + effort: string | null; + tiers: readonly string[]; + onCycle: () => void; +}; + +export type ModelListRowProps = { + model: ModelDescriptor; + isFavorite: boolean; + isActive: boolean; + isAvailable: boolean; + onSelect: (modelId: string) => void; + onToggleFavorite: (modelId: string) => void; + onCopyId?: (modelId: string) => void; + onSetSurfaceDefault?: (modelId: string) => void; + onViewDocs?: (modelId: string) => void; + onSignIn?: () => void; + inlineReasoningChip?: InlineReasoningChipState; +}; + +const REASONING_LABELS: Record = { + minimal: "Minimal", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max", +}; + +function reasoningChipLabel(effort: string | null): string { + if (!effort) return "Off"; + return REASONING_LABELS[effort] ?? effort.charAt(0).toUpperCase() + effort.slice(1); +} + +export const ModelListRow = memo(function ModelListRow({ + model, + isFavorite, + isActive, + isAvailable, + onSelect, + onToggleFavorite, + onCopyId, + onSetSurfaceDefault, + onViewDocs, + onSignIn, + inlineReasoningChip, +}: ModelListRowProps) { + const sub = subProviderLabel(model); + const localBadge = isLocalModel(model); + + const handleSelect = useCallback(() => { + if (!isAvailable) { + onSignIn?.(); + return; + } + onSelect(model.id); + }, [isAvailable, model.id, onSelect, onSignIn]); + + const handleToggleFavorite = useCallback( + (event: React.MouseEvent | React.KeyboardEvent) => { + event.stopPropagation(); + onToggleFavorite(model.id); + }, + [model.id, onToggleFavorite], + ); + + const handleFavoriteKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + event.stopPropagation(); + onToggleFavorite(model.id); + } + }, + [model.id, onToggleFavorite], + ); + + const handleSignInClick = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + onSignIn?.(); + }, + [onSignIn], + ); + + const handleSignInKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + event.stopPropagation(); + onSignIn?.(); + } + }, + [onSignIn], + ); + + const reasoningCycleCb = inlineReasoningChip?.onCycle; + const handleReasoningChipClick = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + reasoningCycleCb?.(); + }, + [reasoningCycleCb], + ); + + const handleReasoningChipKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + event.stopPropagation(); + reasoningCycleCb?.(); + } + }, + [reasoningCycleCb], + ); + + const handleRowKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + // Only react when the event originated on the row itself, not a child + // (the favorite-star button has its own handler). + if (event.target !== event.currentTarget) return; + event.preventDefault(); + handleSelect(); + } + }, + [handleSelect], + ); + + return ( + + +
+ + + + + + + + {model.displayName} + + {localBadge ? ( + + + Local + + ) : null} + + {sub ? ( + + {sub} + + ) : null} + {inlineReasoningChip?.visible ? ( + + ) : null} + + + {!isAvailable && onSignIn ? ( + + ) : null} +
+
+ + + onCopyId?.(model.id)} + disabled={!onCopyId} + label="Copy model id" + /> + onSetSurfaceDefault?.(model.id)} + disabled={!onSetSurfaceDefault} + label="Set as default for this surface" + /> + onViewDocs?.(model.id)} + disabled={!onViewDocs} + label="View model docs" + /> + + +
+ ); +}); + +function ContextMenuItem({ + onSelect, + disabled, + label, +}: { + onSelect: () => void; + disabled?: boolean; + label: string; +}) { + return ( + + {label} + + ); +} diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx new file mode 100644 index 000000000..beac01eb5 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx @@ -0,0 +1,479 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ModelDescriptor } from "../../../../shared/modelRegistry"; + +vi.mock("@lobehub/icons", () => { + const brand = () => { + const Component = () => null; + Object.assign(Component, { + Avatar: () => null, + Color: () => null, + Combine: () => null, + Text: () => null, + colorPrimary: "#888", + title: "stub", + }); + return Component; + }; + return { + Anthropic: brand(), + Claude: brand(), + Codex: brand(), + Cursor: brand(), + Gemini: brand(), + Google: brand(), + Grok: brand(), + Groq: brand(), + Kimi: brand(), + LmStudio: brand(), + Ollama: brand(), + OpenAI: brand(), + OpenCode: brand(), + OpenRouter: brand(), + XAI: brand(), + }; +}); + +const favoriteStore = new Set(); +const recentStore: string[] = []; +let authOnlyState = false; +const reasoningByFamilyStore: Record = {}; +let providerAuthStatusInternal: Record = {}; + +vi.mock("./useModelFavorites", () => ({ + useModelFavorites: () => ({ + favorites: [...favoriteStore], + isFavorite: (id: string) => favoriteStore.has(id), + toggleFavorite: (id: string) => { + if (favoriteStore.has(id)) favoriteStore.delete(id); + else favoriteStore.add(id); + }, + }), +})); + +vi.mock("./useModelRecents", () => ({ + useModelRecents: () => ({ + recents: [...recentStore], + recordUsage: (id: string) => { + const idx = recentStore.indexOf(id); + if (idx !== -1) recentStore.splice(idx, 1); + recentStore.unshift(id); + }, + }), +})); + +vi.mock("./useAuthOnlyFilter", () => ({ + useAuthOnlyFilter: () => ({ + authOnly: authOnlyState, + toggleAuthOnly: () => { + authOnlyState = !authOnlyState; + }, + }), +})); + +vi.mock("./usePerSurfaceModelDefaults", () => ({ + usePerSurfaceModelDefaults: () => ({ + defaults: {} as Record, + setDefault: () => {}, + getDefault: () => null, + }), +})); + +vi.mock("./useReasoningByFamily", () => ({ + useReasoningByFamily: () => ({ + byFamily: { ...reasoningByFamilyStore }, + rememberReasoning: (family: string, effort: string | null) => { + if (effort == null || effort.length === 0) { + delete reasoningByFamilyStore[family]; + } else { + reasoningByFamilyStore[family] = effort; + } + }, + getReasoningForFamily: (family: string) => reasoningByFamilyStore[family] ?? null, + }), +})); + +vi.mock("./useProviderAuthStatus", () => ({ + useProviderAuthStatus: () => ({ + status: { ...providerAuthStatusInternal }, + loaded: true, + }), +})); + +type SearchItem = { + name: string; + shortName?: string; + subProvider?: string; + family: string; + providerDisplayName: string; + isFavorite?: boolean; +}; + +vi.mock("./modelPickerSearch", () => ({ + scoreModelPickerSearch: (item: SearchItem, query: string): number | null => { + const q = query.trim().toLowerCase(); + if (!q.length) return 0; + const hay = `${item.name} ${item.shortName ?? ""} ${item.family}`.toLowerCase(); + return hay.includes(q) ? 0 : null; + }, +})); + +vi.mock("./modelOrdering", () => ({ + sortModelItems: (items: T[]): T[] => [...items], +})); + +import { ModelPicker } from "./ModelPicker"; + +const SONNET: ModelDescriptor = { + id: "anthropic/claude-sonnet-4-6", + shortId: "sonnet", + displayName: "Claude Sonnet 4.6", + family: "anthropic", + authTypes: ["cli-subscription"], + contextWindow: 200_000, + maxOutputTokens: 32_000, + capabilities: { tools: true, vision: true, reasoning: true, streaming: true }, + reasoningTiers: ["low", "medium", "high"], + color: "#8B5CF6", + providerRoute: "claude-cli", + providerModelId: "sonnet", + cliCommand: "claude", + isCliWrapped: true, +}; + +const OPUS: ModelDescriptor = { + id: "anthropic/claude-opus-4-7", + shortId: "opus", + displayName: "Claude Opus 4.7", + family: "anthropic", + authTypes: ["cli-subscription"], + contextWindow: 200_000, + maxOutputTokens: 128_000, + capabilities: { tools: true, vision: true, reasoning: true, streaming: true }, + reasoningTiers: ["low", "medium", "high"], + color: "#D97706", + providerRoute: "claude-cli", + providerModelId: "claude-opus-4-7", + cliCommand: "claude", + isCliWrapped: true, +}; + +const GPT: ModelDescriptor = { + id: "openai/gpt-5.4", + shortId: "gpt-5.4", + displayName: "GPT-5.4", + family: "openai", + authTypes: ["cli-subscription"], + contextWindow: 1_000_000, + maxOutputTokens: 128_000, + capabilities: { tools: true, vision: true, reasoning: true, streaming: true }, + reasoningTiers: ["low", "medium", "high"], + color: "#10A37F", + providerRoute: "codex-cli", + providerModelId: "gpt-5.4", + cliCommand: "codex", + isCliWrapped: true, +}; + +const MODELS: ModelDescriptor[] = [SONNET, OPUS, GPT]; + +beforeEach(() => { + favoriteStore.clear(); + recentStore.length = 0; + authOnlyState = false; + for (const key of Object.keys(reasoningByFamilyStore)) delete reasoningByFamilyStore[key]; + providerAuthStatusInternal = {}; +}); + +afterEach(() => { + cleanup(); +}); + +function renderPicker(overrides: Partial> = {}) { + const onChange = vi.fn(); + const utils = render( + , + ); + return { ...utils, onChange }; +} + +describe("ModelPicker", () => { + it("renders the active model on the trigger and opens the popover on click", async () => { + const user = userEvent.setup(); + renderPicker(); + + const trigger = screen.getByRole("button", { name: /Select model/i }); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + + await user.click(trigger); + + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + expect(screen.getByRole("listbox", { name: /models/i })).toBeTruthy(); + expect(screen.getAllByRole("option").length).toBeGreaterThan(0); + }); + + it("closes the popover when Escape is pressed", async () => { + const user = userEvent.setup(); + renderPicker(); + + const trigger = screen.getByRole("button", { name: /Select model/i }); + await user.click(trigger); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + + await user.keyboard("{Escape}"); + + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + }); + + it("selects a model when its row is clicked", async () => { + const user = userEvent.setup(); + const { onChange } = renderPicker(); + + await user.click(screen.getByRole("button", { name: /Select model/i })); + + const opusRow = screen + .getAllByRole("option") + .find((el) => el.getAttribute("data-model-id") === OPUS.id); + expect(opusRow).toBeDefined(); + + await user.click(opusRow!); + + expect(onChange).toHaveBeenCalledWith(OPUS.id); + }); + + it("filters the list by search query", async () => { + const user = userEvent.setup(); + renderPicker(); + + await user.click(screen.getByRole("button", { name: /Select model/i })); + + const input = screen.getByLabelText(/Search models/i) as HTMLInputElement; + await user.type(input, "opus"); + + const visibleIds = screen + .getAllByRole("option") + .map((el) => el.getAttribute("data-model-id")); + expect(visibleIds).toContain(OPUS.id); + expect(visibleIds).not.toContain(SONNET.id); + }); + + it("toggles favorites when the star button is clicked", async () => { + const user = userEvent.setup(); + renderPicker(); + + await user.click(screen.getByRole("button", { name: /Select model/i })); + + const opusRow = screen + .getAllByRole("option") + .find((el) => el.getAttribute("data-model-id") === OPUS.id)!; + const starButton = opusRow.querySelector("button[aria-pressed]") as HTMLButtonElement; + expect(starButton).toBeTruthy(); + expect(starButton.getAttribute("aria-pressed")).toBe("false"); + + await user.click(starButton); + + expect(favoriteStore.has(OPUS.id)).toBe(true); + }); + + it("does not render popover content when closed", () => { + renderPicker(); + expect(screen.queryByRole("listbox", { name: /models/i })).toBeNull(); + }); + + // Reference act/fireEvent to satisfy "no-unused" linters in the future. + it("imports act and fireEvent without using them at runtime", () => { + expect(typeof act).toBe("function"); + expect(typeof fireEvent.click).toBe("function"); + }); + + it("shows the model name on the trigger even when compact", () => { + renderPicker({ compact: true }); + const trigger = screen.getByRole("button", { name: /Select model/i }); + expect(trigger.textContent).toContain(SONNET.displayName); + }); + + it("renders the reasoning chip on the trigger when showReasoning is set and effort exists", () => { + renderPicker({ + showReasoning: true, + reasoningEffort: "medium", + }); + const trigger = screen.getByRole("button", { name: /Select model/i }); + const chip = trigger.querySelector('[data-model-picker-reasoning-chip="true"]'); + expect(chip).toBeTruthy(); + expect(chip!.textContent).toContain("MED"); + }); + + it("renders the fast-mode toggle outside the trigger when supported", async () => { + const onToggle = vi.fn(); + const FAST: ModelDescriptor = { + ...GPT, + serviceTiers: ["fast"], + }; + render( + , + ); + const fastButton = screen.getByRole("button", { name: /Fast mode/i }); + expect(fastButton.getAttribute("data-model-picker-fast-toggle")).toBe("true"); + const trigger = screen.getByRole("button", { name: /Select model/i }); + expect(trigger.contains(fastButton)).toBe(false); + await userEvent.click(fastButton); + expect(onToggle).toHaveBeenCalledWith(true); + // Clicking fast did NOT open the popover + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + }); + + it("falls back trigger display to most-recent when value is empty", () => { + recentStore.unshift(OPUS.id); + render( + , + ); + const trigger = screen.getByRole("button", { name: /Select model/i }); + expect(trigger.textContent).toContain(OPUS.displayName); + }); + + it("remembers reasoning per family and restores when switching families", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const onReasoningEffortChange = vi.fn(); + // Pre-set memory for openai family + reasoningByFamilyStore.openai = "high"; + render( + , + ); + await user.click(screen.getByRole("button", { name: /Select model/i })); + // Search for the GPT model to make sure it's visible regardless of active rail. + const search = screen.getByLabelText(/Search models/i) as HTMLInputElement; + await user.type(search, "gpt"); + const gptRow = screen + .getAllByRole("option") + .find((el) => el.getAttribute("data-model-id") === GPT.id)!; + expect(gptRow).toBeDefined(); + await user.click(gptRow); + expect(onChange).toHaveBeenCalledWith(GPT.id); + expect(onReasoningEffortChange).toHaveBeenCalledWith("high"); + }); + + it("persists reasoning to family memory when the footer control changes", async () => { + const user = userEvent.setup(); + const onReasoningEffortChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole("button", { name: /Select model/i })); + const radios = screen.getAllByRole("radio"); + const medium = radios.find((el) => el.textContent === "Medium"); + expect(medium).toBeTruthy(); + await user.click(medium!); + expect(onReasoningEffortChange).toHaveBeenCalledWith("medium"); + expect(reasoningByFamilyStore.anthropic).toBe("medium"); + }); + + it("shows the correct tooltip on the authOnly toggle and calls toggle on click", async () => { + const user = userEvent.setup(); + authOnlyState = true; + renderPicker(); + await user.click(screen.getByRole("button", { name: /Select model/i })); + const toggle = document.querySelector( + '[data-model-picker-auth-toggle="true"]', + ) as HTMLButtonElement; + expect(toggle).toBeTruthy(); + expect(toggle.getAttribute("aria-pressed")).toBe("true"); + expect(toggle.getAttribute("title")).toMatch(/click to show all/i); + await user.click(toggle); + expect(authOnlyState).toBe(false); + }); + + it("hides unauthed family rows when authOnly is on (using internal status hook)", async () => { + const user = userEvent.setup(); + authOnlyState = true; + providerAuthStatusInternal = { anthropic: "ok", openai: "unauthed" }; + renderPicker(); + await user.click(screen.getByRole("button", { name: /Select model/i })); + const ids = screen + .getAllByRole("option") + .map((el) => el.getAttribute("data-model-id")); + expect(ids).toContain(SONNET.id); + expect(ids).not.toContain(GPT.id); + }); + + it("shows inline reasoning chips in Recents view and cycles effort without selecting the row", async () => { + const user = userEvent.setup(); + recentStore.unshift(SONNET.id); + reasoningByFamilyStore.anthropic = "low"; + const onChange = vi.fn(); + const onReasoningEffortChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole("button", { name: /Select model/i })); + const sonnetRow = screen + .getAllByRole("option") + .find((el) => el.getAttribute("data-model-id") === SONNET.id)!; + const chip = sonnetRow.querySelector('button[aria-label*="Reasoning effort"]') as HTMLButtonElement; + expect(chip).toBeTruthy(); + expect(chip.textContent).toMatch(/Low/i); + await user.click(chip); + // Cycle from "low" -> "medium" (tiers = ["low","medium","high"]) + expect(reasoningByFamilyStore.anthropic).toBe("medium"); + // Should not select the model + expect(onChange).not.toHaveBeenCalled(); + }); + + it("does not show inline reasoning chips in a provider rail view", async () => { + const user = userEvent.setup(); + reasoningByFamilyStore.anthropic = "low"; + renderPicker(); + await user.click(screen.getByRole("button", { name: /Select model/i })); + // No recents -> initial selection is the active model's provider rail (anthropic). + const sonnetRow = screen + .getAllByRole("option") + .find((el) => el.getAttribute("data-model-id") === SONNET.id)!; + const chip = sonnetRow.querySelector('button[aria-label*="Reasoning effort"]'); + expect(chip).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx new file mode 100644 index 000000000..46e479995 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx @@ -0,0 +1,320 @@ +import { forwardRef, memo, useCallback, useMemo, useState } from "react"; +import * as Popover from "@radix-ui/react-popover"; +import { CaretDown, Lightning } from "@phosphor-icons/react"; +import { + modelSupportsFastMode, + resolveModelDescriptor, + type ModelDescriptor, + type ProviderFamily, +} from "../../../../shared/modelRegistry"; +import { ModelRowLogo } from "../ProviderLogos"; +import { cn } from "../../ui/cn"; +import { ModelPickerContent } from "./ModelPickerContent"; +import type { AuthStatus } from "./ModelPickerRail"; +import { createUnknownModelPlaceholder, mergeSelectorModels } from "./modelCatalog"; +import { useModelRecents } from "./useModelRecents"; +import { useReasoningByFamily } from "./useReasoningByFamily"; + +export type ModelPickerProps = { + value: string; + onChange: (modelId: string) => void; + surfaceKey: string; + compact?: boolean; + disabled?: boolean; + showReasoning?: boolean; + reasoningEffort?: string | null; + onReasoningEffortChange?: (effort: string | null) => void; + availableModelIds?: string[]; + catalogMode?: "all" | "available-only"; + filter?: (model: ModelDescriptor) => boolean; + models?: readonly ModelDescriptor[]; + providerAuthStatus?: Partial>; + onOpenSignIn?: () => void; + fastModeActive?: boolean; + onFastModeToggle?: (next: boolean) => void; + fastModeSupported?: boolean; + className?: string; + triggerClassName?: string; +}; + +function reasoningChipLabel(effort: string | null | undefined): string | null { + if (!effort) return null; + const lower = effort.trim().toLowerCase(); + if (!lower) return null; + if (lower === "minimal") return "MIN"; + if (lower === "low") return "LOW"; + if (lower === "medium") return "MED"; + if (lower === "high") return "HI"; + if (lower === "xhigh") return "XH"; + if (lower === "max") return "MAX"; + return lower.slice(0, 3).toUpperCase(); +} + +export const ModelPicker = memo(function ModelPicker({ + value, + onChange, + surfaceKey, + compact = false, + disabled = false, + showReasoning, + reasoningEffort = null, + onReasoningEffortChange, + availableModelIds, + catalogMode, + filter, + models, + providerAuthStatus, + onOpenSignIn, + fastModeActive = false, + onFastModeToggle, + fastModeSupported, + className, + triggerClassName, +}: ModelPickerProps) { + const [open, setOpen] = useState(false); + const { recents } = useModelRecents(); + const { getReasoningForFamily } = useReasoningByFamily(); + + const modelList = useMemo(() => { + if (models && models.length) return models; + return mergeSelectorModels(availableModelIds, value, filter, catalogMode); + }, [models, availableModelIds, value, filter, catalogMode]); + + const effectiveValue = useMemo(() => { + if (value && value.length > 0) return value; + if (recents.length > 0) { + const fromRecents = recents.find((id) => modelList.some((m) => m.id === id)); + if (fromRecents) return fromRecents; + return recents[0] ?? ""; + } + const firstModel = modelList[0]; + return firstModel ? firstModel.id : ""; + }, [value, recents, modelList]); + + const selectedModel = useMemo(() => { + if (!effectiveValue) return undefined; + return resolveModelDescriptor(effectiveValue) ?? createUnknownModelPlaceholder(effectiveValue); + }, [effectiveValue]); + + const availableSet = useMemo(() => { + if (!availableModelIds) return null; + return new Set(availableModelIds.map((id) => id.trim()).filter(Boolean)); + }, [availableModelIds]); + + const isAvailable = useCallback( + (modelId: string): boolean => { + if (!availableSet) return true; + return availableSet.has(modelId); + }, + [availableSet], + ); + + const handleSelect = useCallback( + (modelId: string) => { + // When selecting a model from a different family, restore that family's + // remembered reasoning effort so callers don't carry stale state across providers. + if (onReasoningEffortChange) { + const previous = selectedModel?.family; + const nextDescriptor = resolveModelDescriptor(modelId); + const nextFamily = nextDescriptor?.family; + if (nextFamily && previous && nextFamily !== previous) { + const remembered = getReasoningForFamily(nextFamily); + onReasoningEffortChange(remembered); + } + } + onChange(modelId); + setOpen(false); + }, + [getReasoningForFamily, onChange, onReasoningEffortChange, selectedModel], + ); + + const handleRequestClose = useCallback(() => { + setOpen(false); + }, []); + + const triggerReasoning = + showReasoning && selectedModel && (selectedModel.reasoningTiers?.length ?? 0) > 0 + ? reasoningChipLabel( + (value && reasoningEffort) || getReasoningForFamily(selectedModel.family), + ) + : null; + + const triggerFastSupported = + typeof fastModeSupported === "boolean" + ? fastModeSupported + : modelSupportsFastMode(selectedModel); + const showFastToggle = triggerFastSupported && typeof onFastModeToggle === "function"; + + return ( +
+ { + if (disabled) { + setOpen(false); + return; + } + setOpen(next); + }} + > + + + + + { + event.preventDefault(); + }} + > + {open ? ( + + ) : null} + + + + {showFastToggle ? ( + + ) : null} +
+ ); +}); + +type TriggerProps = { + model: ModelDescriptor | undefined; + value: string; + compact: boolean; + disabled: boolean; + open: boolean; + reasoningLabel: string | null; + className?: string; +}; + +const ModelPickerTrigger = memo( + forwardRef>( + function ModelPickerTrigger( + { model, value, compact, disabled, open, reasoningLabel, className, ...rest }, + ref, + ) { + const label = model?.displayName ?? value ?? "Select model"; + return ( + + ); + }, + ), +); + +const FastModeButton = memo(function FastModeButton({ + active, + disabled, + compact, + onToggle, +}: { + active: boolean; + disabled: boolean; + compact: boolean; + onToggle?: (next: boolean) => void; +}) { + return ( + + ); +}); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx new file mode 100644 index 000000000..f1df2cd38 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx @@ -0,0 +1,594 @@ +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { MagnifyingGlass, Funnel } from "@phosphor-icons/react"; +import { MODEL_REGISTRY, type ModelDescriptor, type ProviderFamily } from "../../../../shared/modelRegistry"; +import { cn } from "../../ui/cn"; +import { ModelListRow } from "./ModelListRow"; +import { ModelPickerRail, type RailEntry, type RailSelection, type AuthStatus } from "./ModelPickerRail"; +import { ReasoningEffortControl } from "./ReasoningEffortControl"; +import { useModelFavorites } from "./useModelFavorites"; +import { useModelRecents } from "./useModelRecents"; +import { useAuthOnlyFilter } from "./useAuthOnlyFilter"; +import { usePerSurfaceModelDefaults } from "./usePerSurfaceModelDefaults"; +import { useReasoningByFamily } from "./useReasoningByFamily"; +import { useProviderAuthStatus } from "./useProviderAuthStatus"; +import { scoreModelPickerSearch } from "./modelPickerSearch"; +import { sortModelItems } from "./modelOrdering"; + +const PROVIDER_LABELS: Partial> = { + anthropic: "Anthropic", + openai: "OpenAI", + opencode: "OpenCode", + google: "Google", + mistral: "Mistral", + deepseek: "DeepSeek", + xai: "xAI", + groq: "Groq", + together: "Together", + openrouter: "OpenRouter", + ollama: "Ollama", + lmstudio: "LM Studio", + cursor: "Cursor", + factory: "Droid", +}; + +function providerLabel(family: ProviderFamily): string { + return PROVIDER_LABELS[family] ?? family; +} + +function modelSubProvider(model: ModelDescriptor): string { + const sub = (model as ModelDescriptor & { subProvider?: string }).subProvider; + if (typeof sub === "string" && sub.trim().length) return sub.trim(); + if (model.providerRoute === "opencode" && model.openCodeProviderId) { + return `${model.openCodeProviderId} via OpenCode`; + } + return ""; +} + +export type ModelPickerContentProps = { + value: string; + surfaceKey: string; + models: readonly ModelDescriptor[]; + isAvailable: (modelId: string) => boolean; + providerAuthStatus?: Partial>; + onSelect: (modelId: string) => void; + onRequestClose: () => void; + showReasoning?: boolean; + reasoningEffort?: string | null; + onReasoningEffortChange?: (effort: string | null) => void; + onOpenSignIn?: () => void; +}; + +export const ModelPickerContent = memo(function ModelPickerContent({ + value, + surfaceKey, + models, + isAvailable, + providerAuthStatus, + onSelect, + onRequestClose, + showReasoning, + reasoningEffort = null, + onReasoningEffortChange, + onOpenSignIn, +}: ModelPickerContentProps) { + const [query, setQuery] = useState(""); + const searchRef = useRef(null); + const listRef = useRef(null); + + const { favorites, isFavorite, toggleFavorite } = useModelFavorites(); + const { recents, recordUsage } = useModelRecents(); + const { authOnly, toggleAuthOnly } = useAuthOnlyFilter(); + const { setDefault: setSurfaceDefault } = usePerSurfaceModelDefaults(); + const { rememberReasoning, getReasoningForFamily } = useReasoningByFamily(); + const internalAuth = useProviderAuthStatus(); + + const recentSet = useMemo(() => new Set(recents), [recents]); + const favoriteSet = useMemo(() => new Set(favorites), [favorites]); + + const effectiveAuth = useMemo>>(() => { + if (providerAuthStatus && Object.keys(providerAuthStatus).length > 0) { + return providerAuthStatus; + } + return internalAuth.status; + }, [providerAuthStatus, internalAuth.status]); + + const familyIsReady = useCallback( + (family: ProviderFamily): boolean => { + const status = effectiveAuth[family]; + if (status == null) return true; + return status === "ok" || status === "limited"; + }, + [effectiveAuth], + ); + + const expandedModels = useMemo(() => { + if (authOnly) return models; + const merged = new Map(); + for (const m of models) merged.set(m.id, m); + for (const m of MODEL_REGISTRY) { + if (m.deprecated) continue; + if (!merged.has(m.id)) merged.set(m.id, m); + } + return [...merged.values()]; + }, [authOnly, models]); + + const providersPresent = useMemo(() => { + const set = new Set(); + for (const m of expandedModels) set.add(m.family); + return [...set]; + }, [expandedModels]); + + const railEntries = useMemo(() => { + const out: RailEntry[] = [{ kind: "favorites" }, { kind: "recents" }]; + for (const family of providersPresent) { + out.push({ kind: "provider", family, label: providerLabel(family) }); + } + return out; + }, [providersPresent]); + + const initialSelectionRef = useRef(null); + if (initialSelectionRef.current == null) { + if (recents.length > 0) { + initialSelectionRef.current = "recents"; + } else { + const activeModel = expandedModels.find((m) => m.id === value); + initialSelectionRef.current = activeModel + ? `provider:${activeModel.family}` + : providersPresent[0] + ? `provider:${providersPresent[0]}` + : "favorites"; + } + } + const [selection, setSelection] = useState(initialSelectionRef.current); + + useLayoutEffect(() => { + searchRef.current?.focus({ preventScroll: true }); + }, []); + + const handleSelectRail = useCallback((next: RailSelection) => { + setSelection(next); + searchRef.current?.focus({ preventScroll: true }); + }, []); + + // authOnly === true hides models whose family isn't ready; + // when off, all models are shown (including unauthed, which the row dims + offers sign-in). + const filterAvailable = useCallback( + (m: ModelDescriptor): boolean => { + if (!authOnly) return true; + // Prefer auth-derived gate; fall back to caller-provided `isAvailable` if no auth signal exists. + if (Object.keys(effectiveAuth).length > 0) { + return familyIsReady(m.family); + } + return isAvailable(m.id); + }, + [authOnly, effectiveAuth, familyIsReady, isAvailable], + ); + + const searchActive = query.trim().length > 0; + + const toSearchItem = useCallback( + (m: ModelDescriptor) => ({ + name: m.displayName, + shortName: m.shortId, + subProvider: modelSubProvider(m) || undefined, + family: m.family, + providerDisplayName: providerLabel(m.family), + isFavorite: favoriteSet.has(m.id), + }), + [favoriteSet], + ); + + const visibleModels = useMemo(() => { + let pool: ModelDescriptor[] = []; + if (searchActive) { + pool = expandedModels.filter(filterAvailable); + } else if (selection === "favorites") { + pool = expandedModels.filter((m) => favoriteSet.has(m.id)).filter(filterAvailable); + } else if (selection === "recents") { + const order = new Map(recents.map((id, i) => [id, i] as const)); + pool = expandedModels + .filter((m) => recentSet.has(m.id)) + .filter(filterAvailable) + .sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)); + return pool; + } else { + const family = selection.slice("provider:".length) as ProviderFamily; + pool = expandedModels.filter((m) => m.family === family).filter(filterAvailable); + } + + if (searchActive) { + const scored: Array<{ model: ModelDescriptor; score: number }> = []; + for (const m of pool) { + const score = scoreModelPickerSearch(toSearchItem(m), query); + if (score === null) continue; + scored.push({ model: m, score }); + } + scored.sort((a, b) => a.score - b.score); + return scored.map((entry) => entry.model); + } + + const sorted = sortModelItems( + pool.map((m) => ({ modelId: m.id, _model: m })), + { favoriteModelIds: favoriteSet, groupFavorites: true }, + ); + return sorted.map((entry) => entry._model); + }, [ + searchActive, + selection, + expandedModels, + filterAvailable, + favoriteSet, + recentSet, + recents, + query, + toSearchItem, + ]); + + const groupedRows = useMemo(() => { + if (selection === "favorites" || selection === "recents" || searchActive) { + return [{ subProvider: "", models: visibleModels }]; + } + const groups = new Map(); + for (const m of visibleModels) { + const key = modelSubProvider(m); + const list = groups.get(key); + if (list) list.push(m); + else groups.set(key, [m]); + } + const arr = [...groups.entries()].map(([subProvider, modelsInGroup]) => ({ + subProvider, + models: modelsInGroup, + })); + return arr; + }, [selection, searchActive, visibleModels]); + + const showSubHeaders = groupedRows.length > 1; + + const [focusedIndex, setFocusedIndex] = useState(0); + useEffect(() => { + setFocusedIndex(0); + }, [selection, query]); + + const flatVisibleIds = useMemo( + () => visibleModels.map((m) => m.id), + [visibleModels], + ); + + const isAvailableForUse = useCallback( + (m: ModelDescriptor): boolean => { + if (Object.keys(effectiveAuth).length > 0) { + return familyIsReady(m.family) && isAvailable(m.id); + } + return isAvailable(m.id); + }, + [effectiveAuth, familyIsReady, isAvailable], + ); + + const handleListKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onRequestClose(); + return; + } + if (event.key === "ArrowDown") { + event.preventDefault(); + setFocusedIndex((i) => Math.min(i + 1, Math.max(0, flatVisibleIds.length - 1))); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setFocusedIndex((i) => Math.max(0, i - 1)); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + const target = visibleModels[focusedIndex]; + if (!target) return; + if (!isAvailableForUse(target)) { + onOpenSignIn?.(); + return; + } + recordUsage(target.id); + onSelect(target.id); + } + }, + [ + flatVisibleIds, + focusedIndex, + isAvailableForUse, + onOpenSignIn, + onRequestClose, + onSelect, + recordUsage, + visibleModels, + ], + ); + + const handleRowSelect = useCallback( + (modelId: string) => { + recordUsage(modelId); + onSelect(modelId); + }, + [onSelect, recordUsage], + ); + + const handleSetSurfaceDefault = useCallback( + (modelId: string) => { + setSurfaceDefault(surfaceKey, modelId); + }, + [setSurfaceDefault, surfaceKey], + ); + + const handleCopyId = useCallback((modelId: string) => { + try { + void navigator.clipboard.writeText(modelId); + } catch { + // ignore clipboard failures + } + }, []); + + const activeModel = useMemo( + () => expandedModels.find((m) => m.id === value) ?? null, + [expandedModels, value], + ); + + // Pick a "presentation model" used for the reasoning footer when no value is selected + // or the active model has no reasoning tiers. + const reasoningPresentationModel = useMemo(() => { + if (activeModel && (activeModel.reasoningTiers?.length ?? 0) > 0) { + return activeModel; + } + const firstWithReasoning = visibleModels.find((m) => (m.reasoningTiers?.length ?? 0) > 0); + if (firstWithReasoning) return firstWithReasoning; + const anyWithReasoning = expandedModels.find((m) => (m.reasoningTiers?.length ?? 0) > 0); + return anyWithReasoning ?? null; + }, [activeModel, expandedModels, visibleModels]); + + const reasoningTiers = reasoningPresentationModel?.reasoningTiers ?? []; + + const reasoningFamily = reasoningPresentationModel?.family ?? null; + const displayedReasoningEffort = useMemo(() => { + // If the picker is tied to a real active model with explicit effort, use it. + if (activeModel && reasoningEffort) return reasoningEffort; + // Otherwise, fall back to the family-remembered effort for the presentation model. + if (reasoningFamily) return getReasoningForFamily(reasoningFamily); + return reasoningEffort; + }, [activeModel, reasoningEffort, reasoningFamily, getReasoningForFamily]); + + const handleReasoningChange = useCallback( + (next: string | null) => { + if (reasoningFamily) { + rememberReasoning(reasoningFamily, next); + } + onReasoningEffortChange?.(next); + }, + [onReasoningEffortChange, reasoningFamily, rememberReasoning], + ); + + // Inline per-row reasoning chips appear only in Favorites/Recents (and search results), + // never in provider rail views (the dedicated footer covers those). + const showInlineReasoningChips = + !searchActive && (selection === "favorites" || selection === "recents"); + + const cycleReasoningForModel = useCallback( + (model: ModelDescriptor) => { + const tiers = model.reasoningTiers; + if (!tiers || tiers.length === 0) return; + const current = getReasoningForFamily(model.family); + const idx = current ? tiers.indexOf(current) : -1; + const nextIdx = idx < 0 ? 0 : (idx + 1) % tiers.length; + const next = tiers[nextIdx] ?? null; + rememberReasoning(model.family, next); + if (activeModel?.family === model.family) { + onReasoningEffortChange?.(next); + } + }, + [activeModel, getReasoningForFamily, onReasoningEffortChange, rememberReasoning], + ); + + const isEmpty = visibleModels.length === 0; + + // Sticky "Currently using" detection — show when active row is not in the visible window. + const activeRowVisibleRef = useRef(true); + const [activeOutOfView, setActiveOutOfView] = useState(false); + useEffect(() => { + if (!activeModel) return; + if (!flatVisibleIds.includes(activeModel.id)) { + setActiveOutOfView(false); + return; + } + const container = listRef.current; + if (!container) return; + const targetEl = container.querySelector( + `[data-model-id="${cssEscape(activeModel.id)}"]`, + ); + if (!targetEl) { + setActiveOutOfView(true); + return; + } + if (typeof IntersectionObserver === "undefined") return; + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + activeRowVisibleRef.current = entry.isIntersecting; + setActiveOutOfView(!entry.isIntersecting); + } + }, + { root: container, threshold: 0.1 }, + ); + observer.observe(targetEl); + return () => observer.disconnect(); + }, [activeModel, flatVisibleIds]); + + return ( +
+
+ +
+
+ + setQuery(e.target.value)} + placeholder="Search models..." + aria-label="Search models" + className={cn( + "min-w-0 flex-1 bg-transparent text-[12px] font-medium leading-tight", + "text-fg placeholder:text-muted-fg/45 outline-none", + )} + /> + +
+ +
+ {activeOutOfView && activeModel ? ( +
+ Currently using: + {activeModel.displayName} +
+ ) : null} + + {isEmpty ? ( + + ) : ( +
+ {groupedRows.map((group, gi) => ( +
+ {showSubHeaders && group.subProvider ? ( +
+ {group.subProvider} +
+ ) : null} + {group.models.map((m) => { + const indexInFlat = flatVisibleIds.indexOf(m.id); + const isFocused = indexInFlat === focusedIndex; + const isActive = m.id === value; + return ( +
+ cycleReasoningForModel(m), + }, + } + : {})} + /> +
+ ); + })} +
+ ))} +
+ )} +
+ + {showReasoning && onReasoningEffortChange && reasoningTiers.length > 0 ? ( + + ) : null} +
+
+
+ ); +}); + +function cssEscape(value: string): string { + if (typeof CSS !== "undefined" && typeof CSS.escape === "function") { + return CSS.escape(value); + } + return value.replace(/[^a-zA-Z0-9_-]/g, (ch) => `\\${ch}`); +} + +function EmptyState({ + selection, + searchActive, +}: { + selection: RailSelection; + searchActive: boolean; +}) { + let body = "No models match this view."; + if (searchActive) body = "No models match your search."; + else if (selection === "favorites") body = "Star a model to pin it here."; + else if (selection === "recents") body = "Models you use will appear here."; + return ( +
+ {body} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerRail.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerRail.tsx new file mode 100644 index 000000000..4e9fee257 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerRail.tsx @@ -0,0 +1,145 @@ +import { memo, useCallback } from "react"; +import { Star, Clock } from "@phosphor-icons/react"; +import type { ProviderFamily } from "../../../../shared/modelRegistry"; +import { ProviderLogo } from "../ProviderLogos"; +import { cn } from "../../ui/cn"; + +export type RailEntry = + | { kind: "favorites" } + | { kind: "recents" } + | { kind: "provider"; family: ProviderFamily; label: string }; + +export type AuthStatus = "ok" | "unauthed" | "limited"; + +export type RailSelection = "favorites" | "recents" | `provider:${ProviderFamily}`; + +export type ModelPickerRailProps = { + entries: readonly RailEntry[]; + selected: RailSelection; + onSelect: (selection: RailSelection) => void; + providerAuthStatus?: Partial>; +}; + +function entryKey(entry: RailEntry): RailSelection { + if (entry.kind === "favorites") return "favorites"; + if (entry.kind === "recents") return "recents"; + return `provider:${entry.family}`; +} + +export const ModelPickerRail = memo(function ModelPickerRail({ + entries, + selected, + onSelect, + providerAuthStatus, +}: ModelPickerRailProps) { + return ( +
+ {entries.map((entry, index) => { + const key = entryKey(entry); + const isSelected = selected === key; + const dot = + entry.kind === "provider" ? providerAuthStatus?.[entry.family] ?? "ok" : "ok"; + const showDivider = + index > 0 && + (entry.kind === "provider") && + entries[index - 1] && + entries[index - 1]!.kind !== "provider"; + return ( + + ); + })} +
+ ); +}); + +const RailButton = memo(function RailButton({ + entry, + selectionKey, + isSelected, + authStatus, + onSelect, + showDivider, +}: { + entry: RailEntry; + selectionKey: RailSelection; + isSelected: boolean; + authStatus: AuthStatus; + onSelect: (selection: RailSelection) => void; + showDivider: boolean; +}) { + const handleClick = useCallback(() => onSelect(selectionKey), [onSelect, selectionKey]); + + const label = + entry.kind === "favorites" + ? "Favorites" + : entry.kind === "recents" + ? "Recents" + : entry.label; + + const icon = + entry.kind === "favorites" ? ( + + ) : entry.kind === "recents" ? ( + + ) : ( + + ); + + const dotColor = + authStatus === "unauthed" + ? "bg-red-500" + : authStatus === "limited" + ? "bg-amber-400" + : null; + + return ( + <> + {showDivider ?
: null} + + + ); +}); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortControl.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortControl.tsx new file mode 100644 index 000000000..9af604a52 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortControl.tsx @@ -0,0 +1,72 @@ +import { memo } from "react"; +import { cn } from "../../ui/cn"; + +export type ReasoningEffortControlProps = { + effort: string | null; + onChange: (effort: string | null) => void; + tiers: readonly string[]; + disabled?: boolean; + className?: string; +}; + +function tierLabel(tier: string): string { + if (tier === "xhigh") return "Extra High"; + if (tier === "max") return "Max"; + return tier.charAt(0).toUpperCase() + tier.slice(1); +} + +export const ReasoningEffortControl = memo(function ReasoningEffortControl({ + effort, + onChange, + tiers, + disabled = false, + className, +}: ReasoningEffortControlProps) { + const hasTiers = tiers.length > 0; + + return ( +
+
+ + Thinking + +
+ {tiers.map((tier) => { + const isActive = effort === tier; + return ( + + ); + })} +
+
+
+ ); +}); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts new file mode 100644 index 000000000..5a87bdc5e --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { mergeSelectorModels } from "./modelCatalog"; + +describe("mergeSelectorModels", () => { + it("re-buckets OpenCode-routed models into the 'opencode' family so they appear under one rail", () => { + const ids = [ + "opencode/anthropic/claude-sonnet-4-6", + "opencode/openai/gpt-5.4", + ]; + const merged = mergeSelectorModels(ids, undefined, undefined, "available-only"); + const opencodeRouted = merged.filter((model) => model.providerRoute === "opencode"); + expect(opencodeRouted.length).toBeGreaterThanOrEqual(2); + for (const model of opencodeRouted) { + expect(model.family).toBe("opencode"); + } + }); + + it("keeps openCodeProviderId intact so rows can render per-sub-provider logos", () => { + const ids = ["opencode/anthropic/claude-sonnet-4-6"]; + const merged = mergeSelectorModels(ids, undefined, undefined, "available-only"); + const model = merged.find((m) => m.id === "opencode/anthropic/claude-sonnet-4-6"); + expect(model).toBeDefined(); + expect(model?.openCodeProviderId).toBe("anthropic"); + }); + + it("does not change the family of non-OpenCode models", () => { + const ids = ["anthropic/claude-sonnet-4-6"]; + const merged = mergeSelectorModels(ids, undefined, undefined, "available-only"); + const model = merged.find((m) => m.id === "anthropic/claude-sonnet-4-6"); + expect(model).toBeDefined(); + expect(model?.family).toBe("anthropic"); + }); +}); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts new file mode 100644 index 000000000..77ffb3e47 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts @@ -0,0 +1,139 @@ +import { + createDynamicDroidCliModelDescriptor, + createDynamicOpenCodeModelDescriptor, + LOCAL_PROVIDER_LABELS, + MODEL_REGISTRY, + getLocalModelIdTail, + parseDynamicDroidModelRef, + parseDynamicOpenCodeModelRef, + parseLocalProviderFromModelId, + resolveModelDescriptor, + type ModelDescriptor, +} from "../../../../shared/modelRegistry"; +import { PROVIDER_BADGE_COLORS } from "../providerModelSelectorGrouping"; + +export function createUnknownModelPlaceholder(modelId: string): ModelDescriptor { + const openCode = parseDynamicOpenCodeModelRef(modelId); + if (openCode) { + return createDynamicOpenCodeModelDescriptor(openCode.modelId); + } + if (modelId.startsWith("cursor/")) { + const tail = modelId.slice("cursor/".length); + return { + id: modelId, + shortId: tail || modelId, + displayName: tail || modelId, + family: "cursor", + authTypes: ["api-key"], + contextWindow: 0, + maxOutputTokens: 0, + capabilities: { tools: true, vision: false, reasoning: false, streaming: true }, + color: "#A78BFA", + providerRoute: "cursor-sdk", + providerModelId: tail || modelId, + cliCommand: "cursor", + isCliWrapped: false, + }; + } + const droidCli = parseDynamicDroidModelRef(modelId); + if (droidCli) { + return createDynamicDroidCliModelDescriptor(droidCli.providerModelId); + } + const localProvider = parseLocalProviderFromModelId(modelId); + if (localProvider) { + const shortId = getLocalModelIdTail(modelId, localProvider) || modelId; + const brand = LOCAL_PROVIDER_LABELS[localProvider]; + return { + id: modelId, + shortId, + displayName: shortId, + family: localProvider, + authTypes: ["local"], + contextWindow: 0, + maxOutputTokens: 0, + capabilities: { tools: false, vision: false, reasoning: false, streaming: true }, + color: PROVIDER_BADGE_COLORS[localProvider] ?? "#64748B", + providerRoute: "openai-compatible", + providerModelId: shortId, + isCliWrapped: false, + discoverySource: localProvider === "lmstudio" ? "lmstudio-openai" : localProvider, + harnessProfile: "guarded", + aliases: brand ? [brand] : [], + }; + } + return { + id: modelId, + shortId: modelId, + displayName: modelId, + family: "openrouter", + authTypes: ["api-key"], + contextWindow: 0, + maxOutputTokens: 0, + capabilities: { tools: false, vision: false, reasoning: false, streaming: false }, + color: "#6B7280", + providerRoute: "unknown", + providerModelId: modelId, + isCliWrapped: false, + }; +} + +/** + * Models reached via the OpenCode runtime are family-mapped to their underlying + * vendor ("anthropic", "openai", etc.) so they show the correct logo elsewhere + * in the app. The picker groups them under a single "OpenCode" rail entry, so + * we override `family` to `"opencode"` here while keeping `openCodeProviderId` + * intact for sub-provider sub-headers and row logos. + */ +function rebucketOpenCodeFamily(model: ModelDescriptor): ModelDescriptor { + if (model.providerRoute !== "opencode") return model; + if (model.family === "opencode") return model; + return { ...model, family: "opencode" }; +} + +export function mergeSelectorModels( + availableModelIds?: string[], + selectedModelId?: string, + filter?: (model: ModelDescriptor) => boolean, + catalogMode: "all" | "available-only" = "all", +): ModelDescriptor[] { + const merged = new Map(); + const selectedId = String(selectedModelId ?? "").trim(); + const availableIdSet = new Set( + (availableModelIds ?? []) + .map((entry) => String(entry ?? "").trim()) + .filter(Boolean), + ); + if (catalogMode === "all") { + for (const model of MODEL_REGISTRY) { + if (model.deprecated) continue; + if (filter && !filter(model)) continue; + merged.set(model.id, rebucketOpenCodeFamily(model)); + } + } + + for (const rawId of availableIdSet) { + const descriptor = resolveModelDescriptor(rawId); + if (descriptor) { + if (descriptor.deprecated) continue; + if (filter && !filter(descriptor)) continue; + merged.set(descriptor.id, rebucketOpenCodeFamily(descriptor)); + } else { + const placeholder = createUnknownModelPlaceholder(rawId); + if (filter && !filter(placeholder)) continue; + merged.set(placeholder.id, rebucketOpenCodeFamily(placeholder)); + } + } + + if (selectedId && !merged.has(selectedId)) { + const selectedDescriptor = resolveModelDescriptor(selectedId); + if (selectedDescriptor && !selectedDescriptor.deprecated && (!filter || filter(selectedDescriptor))) { + merged.set(selectedDescriptor.id, rebucketOpenCodeFamily(selectedDescriptor)); + } else if (!selectedDescriptor) { + const placeholder = createUnknownModelPlaceholder(selectedId); + if (!filter || filter(placeholder)) { + merged.set(placeholder.id, rebucketOpenCodeFamily(placeholder)); + } + } + } + return [...merged.values()]; +} diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/modelOrdering.test.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/modelOrdering.test.ts new file mode 100644 index 000000000..4ea799afa --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/modelOrdering.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; + +import { sortModelItems } from "./modelOrdering"; + +describe("sortModelItems", () => { + it("preserves the original order when no options are provided", () => { + const items = [ + { modelId: "anthropic/claude-opus-4-7", label: "opus" }, + { modelId: "openai/gpt-5", label: "gpt" }, + { modelId: "anthropic/claude-sonnet-4-6", label: "sonnet" }, + ]; + expect(sortModelItems(items).map((i) => i.modelId)).toEqual([ + "anthropic/claude-opus-4-7", + "openai/gpt-5", + "anthropic/claude-sonnet-4-6", + ]); + }); + + it("groups favorites first when groupFavorites is enabled", () => { + const items = [ + { modelId: "openai/gpt-5" }, + { modelId: "anthropic/claude-opus-4-7" }, + { modelId: "anthropic/claude-sonnet-4-6" }, + ]; + const sorted = sortModelItems(items, { + favoriteModelIds: ["anthropic/claude-opus-4-7"], + groupFavorites: true, + }); + expect(sorted.map((i) => i.modelId)).toEqual([ + "anthropic/claude-opus-4-7", + "openai/gpt-5", + "anthropic/claude-sonnet-4-6", + ]); + }); + + it("does not move favorites when groupFavorites is false", () => { + const items = [ + { modelId: "openai/gpt-5" }, + { modelId: "anthropic/claude-opus-4-7" }, + ]; + const sorted = sortModelItems(items, { + favoriteModelIds: ["anthropic/claude-opus-4-7"], + groupFavorites: false, + }); + expect(sorted.map((i) => i.modelId)).toEqual([ + "openai/gpt-5", + "anthropic/claude-opus-4-7", + ]); + }); + + it("honors an explicit modelIdOrder ahead of original order", () => { + const items = [ + { modelId: "a" }, + { modelId: "b" }, + { modelId: "c" }, + { modelId: "d" }, + ]; + const sorted = sortModelItems(items, { modelIdOrder: ["c", "a"] }); + expect(sorted.map((i) => i.modelId)).toEqual(["c", "a", "b", "d"]); + }); + + it("combines favorites grouping with id ordering", () => { + const items = [ + { modelId: "a" }, + { modelId: "b" }, + { modelId: "c" }, + { modelId: "d" }, + ]; + const sorted = sortModelItems(items, { + favoriteModelIds: new Set(["c"]), + groupFavorites: true, + modelIdOrder: ["b", "d"], + }); + expect(sorted.map((i) => i.modelId)).toEqual(["c", "b", "d", "a"]); + }); + + it("accepts a Set for favoriteModelIds", () => { + const items = [{ modelId: "x" }, { modelId: "y" }]; + const sorted = sortModelItems(items, { + favoriteModelIds: new Set(["y"]), + groupFavorites: true, + }); + expect(sorted.map((i) => i.modelId)).toEqual(["y", "x"]); + }); +}); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/modelOrdering.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/modelOrdering.ts new file mode 100644 index 000000000..e7f900b19 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/modelOrdering.ts @@ -0,0 +1,84 @@ +export interface ModelOrderingItem { + /** ADE models key by `modelId`. `id` is accepted as a fallback for `ModelDescriptor` shape. */ + readonly modelId?: string; + readonly id?: string; +} + +type ReadonlyIdCollection = ReadonlySet | ReadonlyArray; + +function toSet(values: ReadonlyIdCollection | undefined): ReadonlySet { + return values instanceof Set ? values : new Set(values ?? []); +} + +function rankByValue(values: ReadonlyArray): ReadonlyMap { + const map = new Map(); + for (let i = 0; i < values.length; i += 1) { + const value = values[i]; + if (typeof value === "string" && !map.has(value)) map.set(value, i); + } + return map; +} + +function itemKey(item: ModelOrderingItem): string { + return item.modelId ?? item.id ?? ""; +} + +export type SortModelItemsOptions = { + /** Canonical name from the foundation contract. */ + readonly favoriteModelIds?: ReadonlyIdCollection; + /** Alias accepted from component callsites that pass `favorites` directly. */ + readonly favorites?: ReadonlyIdCollection; + /** When true (or `favoriteModelIds`/`favorites` supplied), favorites sort first. */ + readonly groupFavorites?: boolean; + readonly modelIdOrder?: ReadonlyArray; + /** Active model id — pins it to the very top regardless of favorites. */ + readonly activeId?: string | null; +}; + +export function sortModelItems( + items: ReadonlyArray, + options?: SortModelItemsOptions, +): T[] { + const favoritesSource = options?.favoriteModelIds ?? options?.favorites; + const favorites = toSet(favoritesSource); + const groupFavorites = + options?.groupFavorites === true || + (options?.groupFavorites === undefined && favoritesSource !== undefined); + const idOrder = rankByValue(options?.modelIdOrder ?? []); + const activeId = + typeof options?.activeId === "string" && options.activeId.length > 0 ? options.activeId : null; + const originalOrder = new Map(); + for (let i = 0; i < items.length; i += 1) { + const item = items[i]; + if (item !== undefined) originalOrder.set(item, i); + } + + const rankActive = (item: T): number => { + if (activeId === null) return 0; + return itemKey(item) === activeId ? 0 : 1; + }; + const rankFavorite = (item: T): number => { + if (!groupFavorites) return 0; + return favorites.has(itemKey(item)) ? 0 : 1; + }; + const rankIdOrder = (item: T): number => { + const value = idOrder.get(itemKey(item)); + return value === undefined ? Number.POSITIVE_INFINITY : value; + }; + const rankOriginal = (item: T): number => { + const value = originalOrder.get(item); + return value === undefined ? Number.POSITIVE_INFINITY : value; + }; + + return items + .slice() + .sort((left, right) => { + const activeDelta = rankActive(left) - rankActive(right); + if (activeDelta !== 0) return activeDelta; + const favoriteDelta = rankFavorite(left) - rankFavorite(right); + if (favoriteDelta !== 0) return favoriteDelta; + const idOrderDelta = rankIdOrder(left) - rankIdOrder(right); + if (idOrderDelta !== 0) return idOrderDelta; + return rankOriginal(left) - rankOriginal(right); + }); +} diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/modelPickerSearch.test.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/modelPickerSearch.test.ts new file mode 100644 index 000000000..d3f51889e --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/modelPickerSearch.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; + +import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; + +describe("buildModelPickerSearchText", () => { + it("builds provider-agnostic search text from generic fields", () => { + expect( + buildModelPickerSearchText({ + family: "opencode", + providerDisplayName: "opencode", + name: "Claude Opus 4.7", + subProvider: "GitHub Copilot", + }), + ).toBe("claude opus 4.7 github copilot opencode opencode"); + }); +}); + +describe("scoreModelPickerSearch", () => { + it("matches typo-tolerant multi-token queries", () => { + expect( + scoreModelPickerSearch( + { + family: "opencode", + providerDisplayName: "opencode", + name: "Claude Opus 4.7", + subProvider: "GitHub Copilot", + }, + "coplt op", + ), + ).not.toBeNull(); + }); + + it("rejects results when any query token does not match", () => { + expect( + scoreModelPickerSearch( + { + family: "openai", + providerDisplayName: "Codex", + name: "GPT-5 Codex", + }, + "coplt op", + ), + ).toBeNull(); + }); + + it("ranks exact token matches ahead of fuzzier matches", () => { + const exactScore = scoreModelPickerSearch( + { + family: "opencode", + providerDisplayName: "opencode", + name: "Claude Opus 4.7", + subProvider: "GitHub Copilot", + }, + "copilot opus", + ); + const fuzzyScore = scoreModelPickerSearch( + { + family: "opencode", + providerDisplayName: "opencode", + name: "Claude Opus 4.7", + subProvider: "GitHub Copilot", + }, + "coplt op", + ); + + expect(exactScore).not.toBeNull(); + expect(fuzzyScore).not.toBeNull(); + expect(exactScore!).toBeLessThan(fuzzyScore!); + }); + + it("gives favorite models a strong enough ranking boost for partial queries", () => { + const favoriteScore = scoreModelPickerSearch( + { + family: "anthropic", + providerDisplayName: "Claude", + name: "Claude Opus 4.7", + isFavorite: true, + }, + "opu", + ); + const nonFavoriteScore = scoreModelPickerSearch( + { + family: "cursor", + providerDisplayName: "Cursor", + name: "Opus 4.5", + }, + "opu", + ); + + expect(favoriteScore).not.toBeNull(); + expect(nonFavoriteScore).not.toBeNull(); + expect(favoriteScore!).toBeLessThan(nonFavoriteScore!); + }); + + it("does not let the favorite boost outrank clearly better textual matches", () => { + const favoriteScore = scoreModelPickerSearch( + { + family: "anthropic", + providerDisplayName: "Claude", + name: "Claude Opus 4.7", + isFavorite: true, + }, + "opus 4.7", + ); + const nonFavoriteExactScore = scoreModelPickerSearch( + { + family: "cursor", + providerDisplayName: "Cursor", + name: "Opus 4.7", + }, + "opus 4.7", + ); + + expect(favoriteScore).not.toBeNull(); + expect(nonFavoriteExactScore).not.toBeNull(); + expect(nonFavoriteExactScore!).toBeLessThan(favoriteScore!); + }); + + it("matches a provider display name against its models", () => { + expect( + scoreModelPickerSearch( + { + family: "openai", + providerDisplayName: "Codex Personal", + name: "GPT-5 Codex", + }, + "personal", + ), + ).not.toBeNull(); + }); + + it("returns 0 for an empty query and a non-favorite item", () => { + expect( + scoreModelPickerSearch( + { + family: "anthropic", + providerDisplayName: "Claude", + name: "Claude Opus 4.7", + }, + "", + ), + ).toBe(0); + }); +}); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/modelPickerSearch.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/modelPickerSearch.ts new file mode 100644 index 000000000..778a511a4 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/modelPickerSearch.ts @@ -0,0 +1,180 @@ +import type { ProviderFamily } from "../../../../shared/modelRegistry"; + +export type ModelPickerSearchableItem = { + /** Display name. Either `name` or `displayName` must be set. */ + name?: string; + /** Alias accepted to ease passing `ModelDescriptor`-shaped values directly. */ + displayName?: string; + shortName?: string; + subProvider?: string; + family: ProviderFamily; + /** Provider display label. Falls back to `family` when omitted. */ + providerDisplayName?: string; + isFavorite?: boolean; +}; + +function resolveName(item: ModelPickerSearchableItem): string { + return item.name ?? item.displayName ?? ""; +} + +function resolveProviderDisplayName(item: ModelPickerSearchableItem): string { + return item.providerDisplayName ?? item.family; +} + +const MODEL_PICKER_FAVORITE_SCORE_BOOST = 24; + +function normalizeSearchQuery(input: string): string { + const trimmed = input.trim(); + return trimmed ? trimmed.toLowerCase() : ""; +} + +function scoreSubsequenceMatch(value: string, query: string): number | null { + if (!query) return 0; + + let queryIndex = 0; + let firstMatchIndex = -1; + let previousMatchIndex = -1; + let gapPenalty = 0; + + for (let valueIndex = 0; valueIndex < value.length; valueIndex += 1) { + if (value[valueIndex] !== query[queryIndex]) continue; + + if (firstMatchIndex === -1) firstMatchIndex = valueIndex; + if (previousMatchIndex !== -1) gapPenalty += valueIndex - previousMatchIndex - 1; + + previousMatchIndex = valueIndex; + queryIndex += 1; + if (queryIndex === query.length) { + const spanPenalty = valueIndex - firstMatchIndex + 1 - query.length; + const lengthPenalty = Math.min(64, value.length - query.length); + return firstMatchIndex * 2 + gapPenalty * 3 + spanPenalty + lengthPenalty; + } + } + + return null; +} + +function lengthPenalty(value: string, query: string): number { + return Math.min(64, Math.max(0, value.length - query.length)); +} + +function findBoundaryMatchIndex( + value: string, + query: string, + boundaryMarkers: readonly string[], +): number | null { + let bestIndex: number | null = null; + for (const marker of boundaryMarkers) { + const index = value.indexOf(`${marker}${query}`); + if (index === -1) continue; + const matchIndex = index + marker.length; + if (bestIndex === null || matchIndex < bestIndex) bestIndex = matchIndex; + } + return bestIndex; +} + +function scoreQueryMatch(input: { + value: string; + query: string; + exactBase: number; + prefixBase?: number; + boundaryBase?: number; + includesBase?: number; + fuzzyBase?: number; + boundaryMarkers?: readonly string[]; +}): number | null { + const { value, query } = input; + if (!value || !query) return null; + + if (value === query) return input.exactBase; + + if (input.prefixBase !== undefined && value.startsWith(query)) { + return input.prefixBase + lengthPenalty(value, query); + } + + if (input.boundaryBase !== undefined) { + const boundaryIndex = findBoundaryMatchIndex( + value, + query, + input.boundaryMarkers ?? [" ", "-", "_", "/"], + ); + if (boundaryIndex !== null) { + return input.boundaryBase + boundaryIndex * 2 + lengthPenalty(value, query); + } + } + + if (input.includesBase !== undefined) { + const includesIndex = value.indexOf(query); + if (includesIndex !== -1) { + return input.includesBase + includesIndex * 2 + lengthPenalty(value, query); + } + } + + if (input.fuzzyBase !== undefined) { + const fuzzyScore = scoreSubsequenceMatch(value, query); + if (fuzzyScore !== null) return input.fuzzyBase + fuzzyScore; + } + + return null; +} + +function getModelPickerSearchFields(item: ModelPickerSearchableItem): string[] { + return [ + normalizeSearchQuery(resolveName(item)), + ...(item.shortName ? [normalizeSearchQuery(item.shortName)] : []), + ...(item.subProvider ? [normalizeSearchQuery(item.subProvider)] : []), + normalizeSearchQuery(item.family), + normalizeSearchQuery(resolveProviderDisplayName(item)), + buildModelPickerSearchText(item), + ]; +} + +function scoreModelPickerSearchToken( + field: string, + token: string, + fieldBase: number, +): number | null { + return scoreQueryMatch({ + value: field, + query: token, + exactBase: fieldBase, + prefixBase: fieldBase + 2, + boundaryBase: fieldBase + 4, + includesBase: fieldBase + 6, + ...(token.length >= 3 ? { fuzzyBase: fieldBase + 100 } : {}), + }); +} + +export function buildModelPickerSearchText(item: ModelPickerSearchableItem): string { + return normalizeSearchQuery( + [resolveName(item), item.shortName, item.subProvider, item.family, resolveProviderDisplayName(item)] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join(" "), + ); +} + +export function scoreModelPickerSearch( + item: ModelPickerSearchableItem, + query: string, +): number | null { + const tokens = normalizeSearchQuery(query) + .split(/\s+/u) + .filter((token) => token.length > 0); + + if (tokens.length === 0) return 0; + + const fields = getModelPickerSearchFields(item); + let score = 0; + + for (const token of tokens) { + const tokenScores = fields + .map((field, index) => scoreModelPickerSearchToken(field, token, index * 10)) + .filter((fieldScore): fieldScore is number => fieldScore !== null); + + if (tokenScores.length === 0) return null; + + score += Math.min(...tokenScores); + } + + return item.isFavorite ? score - MODEL_PICKER_FAVORITE_SCORE_BOOST : score; +} diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/useAuthOnlyFilter.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/useAuthOnlyFilter.ts new file mode 100644 index 000000000..5aa9792bc --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/useAuthOnlyFilter.ts @@ -0,0 +1,56 @@ +import { create } from "zustand"; +import { useShallow } from "zustand/react/shallow"; + +const STORAGE_KEY = "ade.modelPicker.authOnly.v1"; + +function readPersisted(): boolean { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (raw === null) return true; + return raw !== "false"; + } catch { + return true; + } +} + +function persist(value: boolean): void { + try { + window.localStorage.setItem(STORAGE_KEY, value ? "true" : "false"); + } catch { + // ignore — preference falls back to in-memory state. + } +} + +type AuthOnlyState = { + authOnly: boolean; + toggleAuthOnly: () => void; + setAuthOnly: (value: boolean) => void; +}; + +const useAuthOnlyStore = create((set, get) => ({ + authOnly: typeof window !== "undefined" ? readPersisted() : true, + toggleAuthOnly: () => { + const next = !get().authOnly; + set({ authOnly: next }); + persist(next); + }, + setAuthOnly: (value: boolean) => { + if (get().authOnly === value) return; + set({ authOnly: value }); + persist(value); + }, +})); + +export function useAuthOnlyFilter(): { + authOnly: boolean; + toggleAuthOnly: () => void; + setAuthOnly: (value: boolean) => void; +} { + return useAuthOnlyStore( + useShallow((state) => ({ + authOnly: state.authOnly, + toggleAuthOnly: state.toggleAuthOnly, + setAuthOnly: state.setAuthOnly, + })), + ); +} diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/useModelFavorites.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/useModelFavorites.ts new file mode 100644 index 000000000..ce838614a --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/useModelFavorites.ts @@ -0,0 +1,57 @@ +import { create } from "zustand"; +import { useShallow } from "zustand/react/shallow"; + +const STORAGE_KEY = "ade.modelPicker.favorites.v1"; + +function readPersisted(): string[] { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.filter((value): value is string => typeof value === "string" && value.length > 0); + } catch { + return []; + } +} + +function persist(values: string[]): void { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(values)); + } catch { + // ignore — favorites are convenience state. + } +} + +type FavoritesState = { + favorites: string[]; + toggleFavorite: (modelId: string) => void; + isFavorite: (modelId: string) => boolean; +}; + +const useFavoritesStore = create((set, get) => ({ + favorites: typeof window !== "undefined" ? readPersisted() : [], + toggleFavorite: (modelId: string) => { + const id = modelId.trim(); + if (!id) return; + const current = get().favorites; + const next = current.includes(id) ? current.filter((entry) => entry !== id) : [...current, id]; + set({ favorites: next }); + persist(next); + }, + isFavorite: (modelId: string) => get().favorites.includes(modelId), +})); + +export function useModelFavorites(): { + favorites: string[]; + toggleFavorite: (modelId: string) => void; + isFavorite: (modelId: string) => boolean; +} { + return useFavoritesStore( + useShallow((state) => ({ + favorites: state.favorites, + toggleFavorite: state.toggleFavorite, + isFavorite: state.isFavorite, + })), + ); +} diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.ts new file mode 100644 index 000000000..8c3bc7532 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.ts @@ -0,0 +1,71 @@ +import { create } from "zustand"; +import { useShallow } from "zustand/react/shallow"; + +const STORAGE_KEY = "ade.modelPicker.recents.v1"; +const MAX_RECENTS = 10; +const PERSIST_DEBOUNCE_MS = 500; + +function readPersisted(): string[] { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed + .filter((value): value is string => typeof value === "string" && value.length > 0) + .slice(0, MAX_RECENTS); + } catch { + return []; + } +} + +let persistTimer: ReturnType | null = null; +let pendingValues: string[] | null = null; + +function schedulePersist(values: string[]): void { + pendingValues = values; + if (persistTimer != null) return; + persistTimer = setTimeout(() => { + persistTimer = null; + const toWrite = pendingValues; + pendingValues = null; + if (toWrite == null) return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(toWrite)); + } catch { + // ignore — recents are convenience state. + } + }, PERSIST_DEBOUNCE_MS); +} + +type RecentsState = { + recents: string[]; + recordUsage: (modelId: string) => void; +}; + +const useRecentsStore = create((set, get) => ({ + recents: typeof window !== "undefined" ? readPersisted() : [], + recordUsage: (modelId: string) => { + const id = modelId.trim(); + if (!id) return; + const current = get().recents; + const filtered = current.filter((entry) => entry !== id); + const next = [id, ...filtered].slice(0, MAX_RECENTS); + set({ recents: next }); + schedulePersist(next); + }, +})); + +export function useModelRecents(): { + recents: string[]; + recordUsage: (modelId: string) => void; + recordRecent: (modelId: string) => void; +} { + return useRecentsStore( + useShallow((state) => ({ + recents: state.recents, + recordUsage: state.recordUsage, + recordRecent: state.recordUsage, + })), + ); +} diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/usePerSurfaceModelDefaults.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/usePerSurfaceModelDefaults.ts new file mode 100644 index 000000000..a2cc0dd89 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/usePerSurfaceModelDefaults.ts @@ -0,0 +1,67 @@ +import { create } from "zustand"; +import { useShallow } from "zustand/react/shallow"; + +const STORAGE_KEY = "ade.modelPicker.perSurfaceDefaults.v1"; + +function readPersisted(): Record { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + const out: Record = {}; + for (const [key, value] of Object.entries(parsed as Record)) { + if (typeof key !== "string" || !key) continue; + if (typeof value === "string" && value.length > 0) out[key] = value; + } + return out; + } catch { + return {}; + } +} + +function persist(values: Record): void { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(values)); + } catch { + // ignore — defaults are convenience state. + } +} + +type PerSurfaceDefaultsState = { + defaults: Record; + setDefault: (surfaceKey: string, modelId: string) => void; + getDefault: (surfaceKey: string) => string | null; +}; + +const usePerSurfaceDefaultsStore = create((set, get) => ({ + defaults: typeof window !== "undefined" ? readPersisted() : {}, + setDefault: (surfaceKey: string, modelId: string) => { + const key = surfaceKey.trim(); + const id = modelId.trim(); + if (!key || !id) return; + const current = get().defaults; + if (current[key] === id) return; + const next = { ...current, [key]: id }; + set({ defaults: next }); + persist(next); + }, + getDefault: (surfaceKey: string) => { + const value = get().defaults[surfaceKey.trim()]; + return value && value.length > 0 ? value : null; + }, +})); + +export function usePerSurfaceModelDefaults(): { + defaults: Record; + setDefault: (surfaceKey: string, modelId: string) => void; + getDefault: (surfaceKey: string) => string | null; +} { + return usePerSurfaceDefaultsStore( + useShallow((state) => ({ + defaults: state.defaults, + setDefault: state.setDefault, + getDefault: state.getDefault, + })), + ); +} diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts new file mode 100644 index 000000000..57b8729f4 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts @@ -0,0 +1,82 @@ +import { create } from "zustand"; +import { useShallow } from "zustand/react/shallow"; +import { useEffect } from "react"; +import type { ProviderFamily } from "../../../../shared/modelRegistry"; +import type { AuthStatus } from "./ModelPickerRail"; + +type AuthStatusMap = Partial>; + +type ProviderAuthStore = { + status: AuthStatusMap; + loaded: boolean; + inFlight: Promise | null; + setStatus: (status: AuthStatusMap) => void; + setInFlight: (promise: Promise | null) => void; +}; + +const useProviderAuthStore = create((set) => ({ + status: {}, + loaded: false, + inFlight: null, + setStatus: (status) => set({ status, loaded: true, inFlight: null }), + setInFlight: (promise) => set({ inFlight: promise }), +})); + +function familiesFromStatus(status: { + availableProviders?: { claude?: unknown; codex?: unknown; cursor?: unknown; droid?: unknown }; + opencodeProviders?: Array<{ id: string; connected: boolean }>; +}): AuthStatusMap { + const out: AuthStatusMap = {}; + const claude = status.availableProviders?.claude; + const claudeOk = + typeof claude === "boolean" + ? claude + : Boolean(claude && typeof claude === "object" && (claude as { runtimeAvailable?: boolean }).runtimeAvailable); + out.anthropic = claudeOk ? "ok" : "unauthed"; + out.openai = status.availableProviders?.codex === true ? "ok" : "unauthed"; + out.cursor = status.availableProviders?.cursor === true ? "ok" : "unauthed"; + out.factory = status.availableProviders?.droid === true ? "ok" : "unauthed"; + + const opencodeAny = + Array.isArray(status.opencodeProviders) && status.opencodeProviders.some((p) => p.connected); + if (opencodeAny) out.opencode = "ok"; + + return out; +} + +async function fetchStatus(): Promise { + const store = useProviderAuthStore.getState(); + if (store.inFlight) return store.inFlight; + const ade = (window as unknown as { ade?: { ai?: { getStatus?: (args?: unknown) => Promise } } }).ade; + const getStatus = ade?.ai?.getStatus; + if (typeof getStatus !== "function") { + store.setStatus({}); + return; + } + const promise = (async () => { + try { + const raw = (await getStatus()) as Parameters[0]; + useProviderAuthStore.getState().setStatus(familiesFromStatus(raw ?? {})); + } catch { + useProviderAuthStore.getState().setStatus({}); + } + })(); + store.setInFlight(promise); + return promise; +} + +export function useProviderAuthStatus(): { + status: AuthStatusMap; + loaded: boolean; +} { + const slice = useProviderAuthStore( + useShallow((state) => ({ status: state.status, loaded: state.loaded })), + ); + // Refetch on every mount — picker mounts on popover open, so the user + // signing into a provider in Settings then reopening the picker gets fresh + // status without polling. Concurrent calls are dedup'd via `inFlight`. + useEffect(() => { + void fetchStatus(); + }, []); + return slice; +} diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/useReasoningByFamily.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/useReasoningByFamily.ts new file mode 100644 index 000000000..0a82ec29c --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/useReasoningByFamily.ts @@ -0,0 +1,73 @@ +import { create } from "zustand"; +import { useShallow } from "zustand/react/shallow"; +import type { ProviderFamily } from "../../../../shared/modelRegistry"; + +const STORAGE_KEY = "ade.modelPicker.reasoningByFamily.v1"; + +type ReasoningMap = Partial>; + +function readPersisted(): ReasoningMap { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object") return {}; + const out: ReasoningMap = {}; + for (const [key, value] of Object.entries(parsed as Record)) { + if (typeof value === "string" && value.length > 0) { + out[key as ProviderFamily] = value; + } + } + return out; + } catch { + return {}; + } +} + +function persist(map: ReasoningMap): void { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); + } catch { + // ignore — preference falls back to in-memory state. + } +} + +type ReasoningByFamilyState = { + byFamily: ReasoningMap; + rememberReasoning: (family: ProviderFamily, effort: string | null) => void; +}; + +const useReasoningByFamilyStore = create((set, get) => ({ + byFamily: typeof window !== "undefined" ? readPersisted() : {}, + rememberReasoning: (family, effort) => { + const current = get().byFamily; + const next: ReasoningMap = { ...current }; + if (effort == null || effort.length === 0) { + if (!(family in next)) return; + delete next[family]; + } else { + if (next[family] === effort) return; + next[family] = effort; + } + set({ byFamily: next }); + persist(next); + }, +})); + +export function useReasoningByFamily(): { + byFamily: ReasoningMap; + rememberReasoning: (family: ProviderFamily, effort: string | null) => void; + getReasoningForFamily: (family: ProviderFamily) => string | null; +} { + const slice = useReasoningByFamilyStore( + useShallow((state) => ({ + byFamily: state.byFamily, + rememberReasoning: state.rememberReasoning, + })), + ); + return { + byFamily: slice.byFamily, + rememberReasoning: slice.rememberReasoning, + getReasoningForFamily: (family) => slice.byFamily[family] ?? null, + }; +} diff --git a/apps/desktop/src/renderer/components/shared/ProviderLogos.tsx b/apps/desktop/src/renderer/components/shared/ProviderLogos.tsx index 137ff9d83..a9c37852d 100644 --- a/apps/desktop/src/renderer/components/shared/ProviderLogos.tsx +++ b/apps/desktop/src/renderer/components/shared/ProviderLogos.tsx @@ -9,6 +9,8 @@ import { Grok, Groq, Kimi, + LmStudio, + Ollama, OpenAI, OpenCode, OpenRouter, @@ -165,6 +167,10 @@ export function ProviderLogo({ return ; case "google": return ; + case "ollama": + return ; + case "lmstudio": + return ; default: { const lobeSrc = lobeProviderIconSrc(raw); if (lobeSrc) { @@ -181,6 +187,7 @@ export function ModelRowLogo({ cliCommand, modelId, providerModelId, + openCodeProviderId, size = 13, className, }: { @@ -188,6 +195,7 @@ export function ModelRowLogo({ cliCommand?: string; modelId?: string; providerModelId?: string; + openCodeProviderId?: string; size?: number; className?: string; }) { @@ -195,6 +203,22 @@ export function ModelRowLogo({ const cli = String(cliCommand ?? "").toLowerCase(); const c = lobeMarkClass(className); + // OpenCode-routed models: route the row logo by their underlying sub-provider + // (Anthropic, OpenAI, etc.) rather than the generic OpenCode mark so each row + // is visually distinguishable inside the OpenCode rail. + if (fam === "opencode" && openCodeProviderId) { + const sub = openCodeProviderId.trim().toLowerCase(); + if (sub === "anthropic") return ; + if (sub === "openai") return ; + if (sub === "google") return ; + if (sub === "xai") return ; + if (sub === "groq") return ; + if (sub === "openrouter") return ; + if (sub === "ollama") return ; + if (sub === "lmstudio") return ; + return ; + } + if (fam === "cursor" || cli === "cursor") { const providerModel = resolveCursorProviderModelId(modelId, providerModelId); if (!providerModel.length) { @@ -239,5 +263,13 @@ export function ModelRowLogo({ return ; } + if (fam === "ollama") { + return ; + } + + if (fam === "lmstudio") { + return ; + } + return ; } diff --git a/apps/desktop/src/renderer/components/shared/ProviderModelSelector.tsx b/apps/desktop/src/renderer/components/shared/ProviderModelSelector.tsx deleted file mode 100644 index f3ba75553..000000000 --- a/apps/desktop/src/renderer/components/shared/ProviderModelSelector.tsx +++ /dev/null @@ -1,329 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { AnimatePresence, motion } from "motion/react"; -import { resolveModelDescriptor, type ModelDescriptor } from "../../../shared/modelRegistry"; -import { fadeScale } from "../../lib/motion"; -import { cn } from "../ui/cn"; -import { X } from "@phosphor-icons/react"; -import { ModelRowLogo } from "./ProviderLogos"; -import { createUnknownModelPlaceholder, ModelCatalogPanel } from "./ModelCatalogPanel"; -import { SmartTooltip } from "../ui/SmartTooltip"; - -type ProviderModelSelectorProps = { - value: string; - onChange: (modelId: string) => void; - onOpen?: () => void; - filter?: (model: ModelDescriptor) => boolean; - availableModelIds?: string[]; - catalogMode?: "all" | "available-only"; - layoutVariant?: "standard" | "mobile"; - className?: string; - disabled?: boolean; - showReasoning?: boolean; - reasoningEffort?: string | null; - onReasoningEffortChange?: (effort: string | null) => void; - /** Opens AI / provider settings (e.g. navigate to `/settings?tab=ai#ai-providers`). */ - onOpenAiSettings?: () => void; - /** @deprecated Use `onOpenAiSettings` */ - onConfigureMore?: () => void; - /** Tighter, equal-width toolbar row in the chat composer (model + reasoning). */ - compactToolbar?: boolean; -}; - -const selectCls = cn( - "h-7 rounded-md border border-white/[0.06] bg-white/[0.03] px-2 font-sans text-[10px] text-fg/70", - "outline-none transition-colors duration-150 focus:border-violet-400/30", -); - -/** Compact composer row: match native controls — no filled pill around the select. */ -const selectClsCompactPlain = cn( - "h-8 min-h-8 w-auto min-w-[5.5rem] max-w-full shrink-0 rounded-md border border-transparent bg-transparent px-2 font-sans text-[10px] text-fg/70", - "outline-none transition-colors duration-150 focus-visible:border-violet-400/25 focus-visible:bg-white/[0.04]", -); - -function tierLabel(tier: string): string { - if (tier === "xhigh") return "Extra High"; - return tier.charAt(0).toUpperCase() + tier.slice(1); -} - -export function ProviderModelSelector({ - value, - onChange, - onOpen, - filter, - availableModelIds, - catalogMode = "all", - layoutVariant = "standard", - className, - disabled = false, - showReasoning, - reasoningEffort, - onReasoningEffortChange, - onOpenAiSettings, - onConfigureMore, - compactToolbar = false, -}: ProviderModelSelectorProps) { - const mobileLayout = layoutVariant === "mobile"; - const compactDesktop = compactToolbar && !mobileLayout; - const barSelectCls = compactDesktop ? selectClsCompactPlain : selectCls; - const openSettings = onOpenAiSettings ?? onConfigureMore; - - const [open, setOpen] = useState(false); - const containerRef = useRef(null); - - const selectedModel = useMemo( - () => (value ? resolveModelDescriptor(value) ?? createUnknownModelPlaceholder(value) : undefined), - [value], - ); - const reasoningTiers = selectedModel?.reasoningTiers ?? []; - const showReasoningBlock = Boolean(showReasoning && reasoningTiers.length > 0 && onReasoningEffortChange); - - useEffect(() => { - if (!open) return; - const handler = (event: MouseEvent) => { - const target = event.target as Node; - if (containerRef.current?.contains(target)) return; - const panels = document.querySelectorAll("[data-model-picker-panel='true']"); - for (const el of panels) { - if (el.contains(target)) return; - } - setOpen(false); - }; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - setOpen(false); - } - }; - document.addEventListener("mousedown", handler); - document.addEventListener("keydown", onKeyDown); - return () => { - document.removeEventListener("mousedown", handler); - document.removeEventListener("keydown", onKeyDown); - }; - }, [open]); - - useEffect(() => { - if (disabled && open) { - setOpen(false); - } - }, [disabled, open]); - - const panel = createPortal( - - {open ? ( - <> - setOpen(false)} - /> -
- - { - setOpen(false); - openSettings(); - } - : undefined - } - onSelectModel={(modelId) => { - onChange(modelId); - setOpen(false); - }} - listboxId="model-selector-listbox" - autoFocusSearch - headerTrailing={( - - - - )} - className={cn( - "max-h-[min(82dvh,48rem)] rounded-t-[26px] border-x-0 border-b-0", - mobileLayout - ? "rounded-b-none" - : "sm:max-h-[min(520px,70vh)] sm:rounded-[18px] sm:border sm:border-violet-400/[0.12]", - )} - /> - -
- - ) : null} -
, - document.body, - ); - - const modelTooltipWrap = cn(mobileLayout && "w-full", compactDesktop && "min-w-0 w-auto max-w-full"); - - const modelPickerButton = ( - - - - ); - - const reasoningSelect = showReasoningBlock ? ( - - - - ) : null; - - if (compactDesktop) { - return ( - <> -
- {modelPickerButton} -
- {reasoningSelect ?
{reasoningSelect}
: null} - {panel} - - ); - } - - return ( -
-
- {modelPickerButton} -
- {panel} - {reasoningSelect} -
- ); -} diff --git a/apps/desktop/src/renderer/components/shared/ReviewLaunchModelControls.tsx b/apps/desktop/src/renderer/components/shared/ReviewLaunchModelControls.tsx index b98297f50..d464ee0a2 100644 --- a/apps/desktop/src/renderer/components/shared/ReviewLaunchModelControls.tsx +++ b/apps/desktop/src/renderer/components/shared/ReviewLaunchModelControls.tsx @@ -1,8 +1,7 @@ import React from "react"; -import { useNavigate } from "react-router-dom"; import type { AiSettingsStatus } from "../../../shared/types"; import { deriveConfiguredModelIds } from "../../lib/modelOptions"; -import { ProviderModelSelector } from "./ProviderModelSelector"; +import { ModelPicker } from "./ModelPicker/ModelPicker"; type ReviewLaunchModelControlsProps = { modelId: string; @@ -21,7 +20,6 @@ export function ReviewLaunchModelControls({ disabled = false, className, }: ReviewLaunchModelControlsProps) { - const navigate = useNavigate(); const [availableModelIds, setAvailableModelIds] = React.useState([]); React.useEffect(() => { @@ -52,16 +50,16 @@ export function ReviewLaunchModelControls({ }, []); return ( - onReasoningEffortChange(next ?? "")} - onOpenAiSettings={() => navigate("/settings?tab=ai#ai-providers")} - className={className} + {...(className ? { className } : {})} /> ); } diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx index 697486750..1ba9924a5 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx @@ -631,8 +631,7 @@ describe("WorkViewArea", () => { expect(local.getByText(/final answer/)).toBeTruthy(); expect(local.queryByText("Resume this session with:")).toBeNull(); expect(local.getByLabelText("Continue Claude Code session")).toBeTruthy(); - expect(local.getByRole("button", { name: "Select model" })).toBeTruthy(); - expect(local.getByLabelText("Reasoning effort")).toBeTruthy(); + expect(local.getByRole("button", { name: /Select model/i })).toBeTruthy(); expect(local.getByLabelText("Claude Code permission mode")).toBeTruthy(); expect(local.queryByText("Resume")).toBeNull(); expect(local.getAllByTestId("work-cli-session-header").some((header) => header.getAttribute("data-session-id") === "session-1")).toBe(true); @@ -962,8 +961,7 @@ describe("WorkViewArea", () => { const textarea = await within(view.container).findByLabelText("Continue Codex session"); await waitFor(() => { - expect(within(view.container).getByRole("button", { name: "Select model" })).toBeTruthy(); - expect((within(view.container).getByLabelText("Reasoning effort") as HTMLSelectElement).value).toBe("high"); + expect(within(view.container).getByRole("button", { name: /Select model/i })).toBeTruthy(); }); fireEvent.change(textarea, { target: { value: "fix the test" } }); fireEvent.keyDown(textarea, { key: "Enter" }); diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index 8672b9b49..975c0b371 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -1,5 +1,4 @@ import { useMemo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; -import { createPortal } from "react-dom"; import { AnimatePresence, motion } from "motion/react"; import { ArrowSquareOut, @@ -43,7 +42,7 @@ import { LaneChip } from "./LaneChip"; import { AgentChatPane, type AgentChatSessionCreatedOptions } from "../chat/AgentChatPane"; import { ChatCommandMenu, handleCommandMenuKeyDown, type ChatCommandMenuHandle, type ChatCommandMenuItem } from "../chat/ChatCommandMenu"; import { ChatComposerShell } from "../chat/ChatComposerShell"; -import { ProviderModelSelector } from "../shared/ProviderModelSelector"; +import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; import { getPermissionOptions, safetyColors, type PermissionOption } from "../shared/permissionOptions"; import { WorkStartSurface } from "./WorkStartSurface"; import { WorkCliSessionHeader } from "./WorkCliSessionHeader"; @@ -393,47 +392,25 @@ function WorkCliPermissionPicker({ value, onChange, disabled, + compact = false, }: { provider: TerminalResumeProvider | null; value: AgentChatPermissionMode; onChange: (mode: AgentChatPermissionMode) => void; disabled?: boolean; + compact?: boolean; }) { const options = useMemo(() => continuationPermissionOptions(provider), [provider]); const selected = options.find((option) => option.value === value) ?? options[0] ?? null; const providerLabel = continuationProviderLabel(provider); const containerRef = useRef(null); - const [panelStyle, setPanelStyle] = useState(null); const [open, setOpen] = useState(false); - const updatePanelStyle = useCallback(() => { - const rect = containerRef.current?.getBoundingClientRect(); - if (!rect) return; - const viewportPadding = 8; - const panelWidth = Math.min(352, Math.max(240, window.innerWidth - viewportPadding * 2)); - const availableBelow = window.innerHeight - rect.bottom - viewportPadding; - const availableAbove = rect.top - viewportPadding; - const openAbove = availableBelow < 180 && availableAbove > availableBelow; - const maxHeight = Math.max(160, Math.min(360, (openAbove ? availableAbove : availableBelow) - 6)); - const left = Math.min( - Math.max(viewportPadding, rect.right - panelWidth), - Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding), - ); - const top = openAbove - ? Math.max(viewportPadding, rect.top - maxHeight - 6) - : Math.max(viewportPadding, Math.min(window.innerHeight - viewportPadding - maxHeight, rect.bottom + 6)); - setPanelStyle({ top, left, width: panelWidth, maxHeight }); - }, []); useEffect(() => { if (!open) return; - updatePanelStyle(); const handlePointerDown = (event: MouseEvent) => { const target = event.target as Node; if (containerRef.current?.contains(target)) return; - const panels = document.querySelectorAll("[data-cli-permission-picker-panel='true']"); - for (const panel of panels) { - if (panel.contains(target)) return; - } setOpen(false); }; const handleKeyDown = (event: KeyboardEvent) => { @@ -441,15 +418,11 @@ function WorkCliPermissionPicker({ }; document.addEventListener("mousedown", handlePointerDown); document.addEventListener("keydown", handleKeyDown); - window.addEventListener("resize", updatePanelStyle); - window.addEventListener("scroll", updatePanelStyle, true); return () => { document.removeEventListener("mousedown", handlePointerDown); document.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("resize", updatePanelStyle); - window.removeEventListener("scroll", updatePanelStyle, true); }; - }, [open, updatePanelStyle]); + }, [open]); useEffect(() => { if (disabled && open) setOpen(false); @@ -457,18 +430,47 @@ function WorkCliPermissionPicker({ if (!selected) return null; - const panel = createPortal( - + return ( +
+ {open ? ( - {options.map((option) => { const optionColors = safetyColors(option.safety); @@ -477,15 +479,16 @@ function WorkCliPermissionPicker({ ); })} - +
) : null} -
, - document.body, - ); - - return ( -
- - {panel}
); } @@ -700,10 +673,11 @@ function WorkCliContinuationComposer({ {providerLabel}
{modelProvider ? ( - ( @@ -711,7 +685,7 @@ function WorkCliContinuationComposer({ ? model.family === "anthropic" && model.isCliWrapped : model.family === "openai" && model.isCliWrapped )} - compactToolbar + compact showReasoning reasoningEffort={selectedReasoningEffort} onReasoningEffortChange={setSelectedReasoningEffort} diff --git a/apps/desktop/src/shared/modelRegistry.ts b/apps/desktop/src/shared/modelRegistry.ts index 636bd3094..15018c0f0 100644 --- a/apps/desktop/src/shared/modelRegistry.ts +++ b/apps/desktop/src/shared/modelRegistry.ts @@ -776,7 +776,7 @@ export function sortCursorCliDescriptorsForPicker(descriptors: ModelDescriptor[] } // --------------------------------------------------------------------------- -// Factory Droid CLI — dynamic descriptors (`droid/`) +// Factory Droid SDK — dynamic descriptors (`droid/`) backed by the local Droid CLI. // --------------------------------------------------------------------------- export type DroidCliLineGroup = "anthropic" | "openai" | "google" | "other" | "custom"; diff --git a/apps/desktop/tsup.config.ts b/apps/desktop/tsup.config.ts index 8bccdcf2c..4fb1b32d9 100644 --- a/apps/desktop/tsup.config.ts +++ b/apps/desktop/tsup.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: { "main/main": "src/main/main.ts", "main/cursorSdkWorker": "src/main/services/chat/cursorSdkWorker.ts", + "main/droidSdkWorker": "src/main/services/chat/droidSdkWorker.ts", "main/packagedRuntimeSmoke": "src/main/packagedRuntimeSmoke.ts", "preload/preload": "src/preload/preload.ts" }, @@ -13,7 +14,7 @@ export default defineConfig({ // Electron provides the "electron" module at runtime; bundling the npm package breaks it. // sql.js loads a wasm file from disk; keep it external so it can resolve its assets. // node-pty is native and must be resolved at runtime for Electron. - external: ["electron", "sql.js", "node-pty", "onnxruntime-node", "@cursor/sdk", "sqlite3"], + external: ["electron", "sql.js", "node-pty", "onnxruntime-node", "@cursor/sdk", "@factory/droid-sdk", "sqlite3"], // @opencode-ai/sdk is ESM-only (no "require" export); force-inline it so // the CJS bundle doesn't emit a bare require() that Node/Electron can't resolve. noExternal: ["@opencode-ai/sdk"], From 6cbfe684c39703ea29d2c2806c4d3cafd75810db Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 18 May 2026 05:18:08 -0400 Subject: [PATCH 02/14] TUI ModelPicker right-pane overhaul + cross-surface favorites/recents sync Adds a unified favorites/recents/providers picker in the ADE CLI right pane, triggered by /model or the model row in new-chat-setup. Authoritative storage for favorites and recents moves from desktop localStorage into the ade-cli process so desktop, TUI, and iOS all share one source of truth. ade-cli - services/modelPickerStore.ts: process-singleton JSON-backed store at ~/.ade/modelPicker.json, capped at 10 recents, debounced writes, tolerates malformed files. - adeRpcServer.ts: modelPicker.{getFavorites,setFavorites,toggleFavorite, getRecents,pushRecent} JSON-RPC methods. - tuiClient/components/ModelPicker/: Ink picker pane with mini-rail, search, windowed list, keyboard handling (tab/shift-tab cycles rail, f favorites, / search, arrows move, enter selects, esc closes/clears). - tuiClient/app.tsx: hydrates favorites/recents on connect, /model and chat:modelPicker now open the right-pane picker, and Enter on the model row in new-chat-setup opens the picker with surface=new-chat (escape and commit return to the setup pane). Desktop - useModelFavorites / useModelRecents: optimistic local update + RPC commit via window.ade.modelPicker.* (localStorage kept as offline cache so first render stays instant). - preload.ts + global.d.ts: window.ade.modelPicker namespace wired through callProjectRuntimeSyncOr. ModelStatus and the inline model row are intentionally untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ade-cli/src/adeRpcServer.ts | 38 +++ .../src/services/modelPickerStore.test.ts | 85 +++++ apps/ade-cli/src/services/modelPickerStore.ts | 130 ++++++++ apps/ade-cli/src/tuiClient/adeApi.ts | 40 ++- apps/ade-cli/src/tuiClient/app.tsx | 295 +++++++++++++++++- .../ModelPicker/ModelPickerPane.tsx | 223 +++++++++++++ .../ModelPicker/modelPickerLayout.test.ts | 142 +++++++++ .../ModelPicker/modelPickerLayout.ts | 205 ++++++++++++ .../tuiClient/components/ModelPicker/types.ts | 31 ++ .../src/tuiClient/components/RightPane.tsx | 39 ++- apps/ade-cli/src/tuiClient/types.ts | 16 + apps/desktop/src/preload/global.d.ts | 9 + apps/desktop/src/preload/preload.ts | 21 ++ .../shared/ModelPicker/useModelFavorites.ts | 73 ++++- .../shared/ModelPicker/useModelRecents.ts | 93 ++++-- 15 files changed, 1409 insertions(+), 31 deletions(-) create mode 100644 apps/ade-cli/src/services/modelPickerStore.test.ts create mode 100644 apps/ade-cli/src/services/modelPickerStore.ts create mode 100644 apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx create mode 100644 apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.test.ts create mode 100644 apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts create mode 100644 apps/ade-cli/src/tuiClient/components/ModelPicker/types.ts diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index ddff5710f..1a4b9b044 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -67,6 +67,18 @@ import { import type { AgentChatPermissionMode, TerminalSessionSummary } from "../../desktop/src/shared/types"; import type { AdeRuntime } from "./bootstrap"; import { JsonRpcError, JsonRpcErrorCode, type JsonRpcHandler, type JsonRpcRequest } from "./jsonrpc"; +import { createModelPickerStore, type ModelPickerStore } from "./services/modelPickerStore"; + +// Cross-surface (desktop + TUI + iOS) model picker favorites & recents. +// Process-singleton so concurrent JSON-RPC sessions see the same in-memory state. +// Persistence path is ~/.ade/modelPicker.json — see modelPickerStore.ts for schema. +let sharedModelPickerStore: ModelPickerStore | null = null; +function getSharedModelPickerStore(): ModelPickerStore { + if (!sharedModelPickerStore) { + sharedModelPickerStore = createModelPickerStore(); + } + return sharedModelPickerStore; +} type ToolSpec = { name: string; @@ -7763,6 +7775,32 @@ export function createAdeRpcRequestHandler(args: { } } + if (method.startsWith("modelPicker.")) { + const store = getSharedModelPickerStore(); + if (method === "modelPicker.getFavorites") { + return { favorites: store.getFavorites() }; + } + if (method === "modelPicker.setFavorites") { + const rawFavorites = (params as { favorites?: unknown }).favorites; + const favoritesInput = Array.isArray(rawFavorites) + ? rawFavorites.filter((entry): entry is string => typeof entry === "string") + : []; + return { favorites: store.setFavorites(favoritesInput) }; + } + if (method === "modelPicker.toggleFavorite") { + const modelId = typeof params.modelId === "string" ? params.modelId : ""; + return store.toggleFavorite(modelId); + } + if (method === "modelPicker.getRecents") { + return { recents: store.getRecents() }; + } + if (method === "modelPicker.pushRecent") { + const modelId = typeof params.modelId === "string" ? params.modelId : ""; + return { recents: store.pushRecent(modelId) }; + } + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unknown modelPicker method: ${method}`); + } + if (method === "ade/actions/list") { return await listActions(); } diff --git a/apps/ade-cli/src/services/modelPickerStore.test.ts b/apps/ade-cli/src/services/modelPickerStore.test.ts new file mode 100644 index 000000000..a302a4126 --- /dev/null +++ b/apps/ade-cli/src/services/modelPickerStore.test.ts @@ -0,0 +1,85 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { createModelPickerStore, MODEL_PICKER_MAX_RECENTS } from "./modelPickerStore"; + +function tempFile(name: string): string { + return path.join(os.tmpdir(), `ade-model-picker-${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${name}`); +} + +describe("modelPickerStore", () => { + it("starts empty when the persistence file is missing", () => { + const filePath = tempFile("missing.json"); + const store = createModelPickerStore({ filePath }); + expect(store.getFavorites()).toEqual([]); + expect(store.getRecents()).toEqual([]); + }); + + it("toggleFavorite adds and removes entries and reports state", () => { + const filePath = tempFile("toggle.json"); + const store = createModelPickerStore({ filePath }); + const first = store.toggleFavorite("claude-opus-4-7"); + expect(first).toEqual({ favorites: ["claude-opus-4-7"], isFavorite: true }); + const second = store.toggleFavorite("gpt-5"); + expect(second.favorites).toEqual(["claude-opus-4-7", "gpt-5"]); + expect(second.isFavorite).toBe(true); + const third = store.toggleFavorite("claude-opus-4-7"); + expect(third).toEqual({ favorites: ["gpt-5"], isFavorite: false }); + }); + + it("setFavorites dedupes, trims, and persists", () => { + const filePath = tempFile("set-favorites.json"); + const store = createModelPickerStore({ filePath }); + const result = store.setFavorites(["a", "a", " b ", "", "c"]); + expect(result).toEqual(["a", "b", "c"]); + const reloaded = createModelPickerStore({ filePath }); + expect(reloaded.getFavorites()).toEqual(["a", "b", "c"]); + }); + + it("pushRecent prepends, dedupes, and caps at MAX_RECENTS", () => { + const filePath = tempFile("recents.json"); + const store = createModelPickerStore({ filePath }); + for (let i = 0; i < MODEL_PICKER_MAX_RECENTS + 5; i += 1) { + store.pushRecent(`model-${i}`); + } + const recents = store.getRecents(); + expect(recents).toHaveLength(MODEL_PICKER_MAX_RECENTS); + expect(recents[0]).toBe(`model-${MODEL_PICKER_MAX_RECENTS + 4}`); + // Re-pushing an existing entry should move it to head without growing the list. + const before = store.getRecents(); + const head = before[before.length - 1]; + expect(head).toBeDefined(); + if (!head) throw new Error("unreachable"); + const moved = store.pushRecent(head); + expect(moved[0]).toBe(head); + expect(moved).toHaveLength(MODEL_PICKER_MAX_RECENTS); + expect(new Set(moved).size).toBe(MODEL_PICKER_MAX_RECENTS); + }); + + it("ignores empty/whitespace modelId in toggleFavorite and pushRecent", () => { + const filePath = tempFile("empty.json"); + const store = createModelPickerStore({ filePath }); + expect(store.toggleFavorite("")).toEqual({ favorites: [], isFavorite: false }); + expect(store.toggleFavorite(" ")).toEqual({ favorites: [], isFavorite: false }); + expect(store.pushRecent("")).toEqual([]); + }); + + it("tolerates a malformed persistence file", () => { + const filePath = tempFile("malformed.json"); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, "this is not json", "utf8"); + const store = createModelPickerStore({ filePath }); + expect(store.getFavorites()).toEqual([]); + expect(store.getRecents()).toEqual([]); + }); + + it("flushes pending recents so reload sees them", () => { + const filePath = tempFile("flush.json"); + const store = createModelPickerStore({ filePath }); + store.pushRecent("alpha"); + store.flush(); + const reloaded = createModelPickerStore({ filePath }); + expect(reloaded.getRecents()).toEqual(["alpha"]); + }); +}); diff --git a/apps/ade-cli/src/services/modelPickerStore.ts b/apps/ade-cli/src/services/modelPickerStore.ts new file mode 100644 index 000000000..e884b96bb --- /dev/null +++ b/apps/ade-cli/src/services/modelPickerStore.ts @@ -0,0 +1,130 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const MAX_RECENTS = 10; +const STORE_VERSION = 1; +const PERSIST_DEBOUNCE_MS = 250; + +type PersistedShape = { + version: number; + favorites: string[]; + recents: string[]; +}; + +export type ModelPickerStore = { + getFavorites: () => string[]; + setFavorites: (favorites: string[]) => string[]; + toggleFavorite: (modelId: string) => { favorites: string[]; isFavorite: boolean }; + getRecents: () => string[]; + pushRecent: (modelId: string) => string[]; + /** Flush any pending debounced write. Exposed for tests/teardown. */ + flush: () => void; +}; + +export type CreateModelPickerStoreOptions = { + filePath?: string; +}; + +function defaultFilePath(): string { + return path.join(os.homedir(), ".ade", "modelPicker.json"); +} + +function sanitizeIdList(values: unknown): string[] { + if (!Array.isArray(values)) return []; + const seen = new Set(); + const out: string[] = []; + for (const entry of values) { + if (typeof entry !== "string") continue; + const trimmed = entry.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + out.push(trimmed); + } + return out; +} + +function readPersisted(filePath: string): PersistedShape { + try { + const raw = fs.readFileSync(filePath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object") { + return { version: STORE_VERSION, favorites: [], recents: [] }; + } + const record = parsed as Record; + return { + version: typeof record.version === "number" ? record.version : STORE_VERSION, + favorites: sanitizeIdList(record.favorites), + recents: sanitizeIdList(record.recents).slice(0, MAX_RECENTS), + }; + } catch { + return { version: STORE_VERSION, favorites: [], recents: [] }; + } +} + +function writePersisted(filePath: string, state: PersistedShape): void { + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const body = JSON.stringify(state, null, 2); + fs.writeFileSync(filePath, body, "utf8"); + } catch { + // best-effort — favorites/recents are convenience state, not load-bearing. + } +} + +export function createModelPickerStore(options: CreateModelPickerStoreOptions = {}): ModelPickerStore { + const filePath = options.filePath ?? defaultFilePath(); + const state = readPersisted(filePath); + + let persistTimer: ReturnType | null = null; + const persistSoon = () => { + if (persistTimer != null) return; + persistTimer = setTimeout(() => { + persistTimer = null; + writePersisted(filePath, { ...state, version: STORE_VERSION }); + }, PERSIST_DEBOUNCE_MS); + }; + const flush = () => { + if (persistTimer != null) { + clearTimeout(persistTimer); + persistTimer = null; + } + writePersisted(filePath, { ...state, version: STORE_VERSION }); + }; + + return { + getFavorites: () => state.favorites.slice(), + setFavorites: (favorites) => { + state.favorites = sanitizeIdList(favorites); + flush(); + return state.favorites.slice(); + }, + toggleFavorite: (modelId) => { + const id = typeof modelId === "string" ? modelId.trim() : ""; + if (!id) return { favorites: state.favorites.slice(), isFavorite: false }; + const idx = state.favorites.indexOf(id); + let isFavorite: boolean; + if (idx >= 0) { + state.favorites = [...state.favorites.slice(0, idx), ...state.favorites.slice(idx + 1)]; + isFavorite = false; + } else { + state.favorites = [...state.favorites, id]; + isFavorite = true; + } + flush(); + return { favorites: state.favorites.slice(), isFavorite }; + }, + getRecents: () => state.recents.slice(), + pushRecent: (modelId) => { + const id = typeof modelId === "string" ? modelId.trim() : ""; + if (!id) return state.recents.slice(); + const filtered = state.recents.filter((entry) => entry !== id); + state.recents = [id, ...filtered].slice(0, MAX_RECENTS); + persistSoon(); + return state.recents.slice(); + }, + flush, + }; +} + +export const MODEL_PICKER_MAX_RECENTS = MAX_RECENTS; diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index ca5fb1190..f12438396 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -363,7 +363,7 @@ export async function createChatSession(args: { : provider === "cursor" ? "auto" : provider === "droid" - ? "claude-sonnet-4-5-20250929" + ? (getDefaultModelDescriptor("droid")?.providerModelId ?? "claude-sonnet-4-5-20250929") : "gpt-5.5"; const reasoningEffort = args.reasoningEffort ?? (provider === "codex" ? DEFAULT_CODEX_REASONING_EFFORT : null); return await args.connection.action("chat", "createSession", { @@ -533,6 +533,44 @@ export async function navigateDesktop(connection: AdeCodeConnection, request: Na return await connection.request("app/navigate", request); } +// --------------------------------------------------------------------------- +// Model picker: cross-surface favorites + recents persisted in ade-cli. +// --------------------------------------------------------------------------- + +export async function getModelPickerFavorites(connection: AdeCodeConnection): Promise { + const result = await connection.request<{ favorites: string[] }>("modelPicker.getFavorites", {}); + return Array.isArray(result?.favorites) ? result.favorites : []; +} + +export async function toggleModelPickerFavorite( + connection: AdeCodeConnection, + modelId: string, +): Promise<{ favorites: string[]; isFavorite: boolean }> { + const result = await connection.request<{ favorites: string[]; isFavorite: boolean }>( + "modelPicker.toggleFavorite", + { modelId }, + ); + return { + favorites: Array.isArray(result?.favorites) ? result.favorites : [], + isFavorite: Boolean(result?.isFavorite), + }; +} + +export async function getModelPickerRecents(connection: AdeCodeConnection): Promise { + const result = await connection.request<{ recents: string[] }>("modelPicker.getRecents", {}); + return Array.isArray(result?.recents) ? result.recents : []; +} + +export async function pushModelPickerRecent( + connection: AdeCodeConnection, + modelId: string, +): Promise { + const result = await connection.request<{ recents: string[] }>("modelPicker.pushRecent", { + modelId, + }); + return Array.isArray(result?.recents) ? result.recents : []; +} + export function newestSession(sessions: AgentChatSessionSummary[]): AgentChatSessionSummary | null { return [...sessions].sort((left, right) => ( new Date(right.lastActivityAt ?? right.startedAt).getTime() diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 50e09babb..5937099ab 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -47,6 +47,10 @@ import { getAiSettingsStatus, getChatHistory, getContextUsage, + getModelPickerFavorites, + getModelPickerRecents, + pushModelPickerRecent, + toggleModelPickerFavorite, getOpenCodeRuntimeDiagnostics, getSlashCommands, getStoredApiKeyProviders, @@ -97,6 +101,7 @@ import { import { TerminalPane, clampTerminalPaneCols } from "./components/TerminalPane"; import { Header } from "./components/Header"; import { computeLaneChatCounts, LANE_DETAIL_ACTIONS, RightPane } from "./components/RightPane"; +import { buildModelPickerLayout, defaultSelectionFor } from "./components/ModelPicker/modelPickerLayout"; import { SlashPalette, SLASH_PALETTE_ROWS } from "./components/SlashPalette"; import { MentionPalette, MENTION_PALETTE_ROWS } from "./components/MentionPalette"; import { ApprovalPrompt } from "./components/ApprovalPrompt"; @@ -1900,6 +1905,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const [footerControl, setFooterControl] = useState(null); const [inlineRowFocus, setInlineRowFocus] = useState<{ cell: 'provider' | 'model' | 'reasoning' | 'permission' | 'subagents' | null }>({ cell: null }); const inlineRowFocused = inlineRowFocus.cell !== null; + // Cross-surface model picker favorites/recents — authoritative copy lives in ade-cli. + const [modelPickerFavorites, setModelPickerFavorites] = useState([]); + const [modelPickerRecents, setModelPickerRecents] = useState([]); const connectionRef = useRef(null); const activeLaneIdRef = useRef(null); @@ -3622,6 +3630,96 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } ).catch(() => undefined); }, [addNotice, focusDetails, loadProviderModels, modelPickerRows, modelSetupRows, modelState.provider, refreshAiSetupStatus]); + // Hydrate favorites/recents from the ade-cli RPC once the connection is up. + useEffect(() => { + const conn = connectionRef.current; + if (!conn) return; + let cancelled = false; + void (async () => { + try { + const [favorites, recents] = await Promise.all([ + getModelPickerFavorites(conn).catch(() => [] as string[]), + getModelPickerRecents(conn).catch(() => [] as string[]), + ]); + if (cancelled) return; + setModelPickerFavorites(favorites); + setModelPickerRecents(recents); + } catch { + // Best-effort hydration — picker still functions with empty state. + } + })(); + return () => { + cancelled = true; + }; + }, [socketPath]); + + // Right-pane model picker — replaces the inline-row focus path when launched + // via /model or new-chat. Reuses the same data the inline row uses (models) + // plus favorites/recents sourced from ade-cli for cross-surface sync. + const openModelPicker = useCallback( + (options: { surface?: "chat" | "new-chat" } = {}) => { + const surface = options.surface ?? "chat"; + // Build a starter selection from current activeModelId/recents so the + // picker opens with relevant content already filtered. + const provider = modelState.provider; + const layoutSeed = buildModelPickerLayout({ + models, + favorites: modelPickerFavorites, + recents: modelPickerRecents, + activeModelId: modelState.modelId, + query: "", + selection: { kind: "provider", provider }, + focusedIndex: 0, + searchMode: false, + }); + const selection = defaultSelectionFor( + modelState.modelId, + modelPickerRecents, + layoutSeed.railEntries, + ); + setRightPane({ + kind: "model-picker", + surface, + query: "", + searchMode: false, + selection, + focusedIndex: 0, + }); + setRightOpen(true); + setPaneFocus("details"); + void refreshAiSetupStatus().catch(() => undefined); + void loadProviderModels(provider, { applyDefault: false }).catch(() => undefined); + }, + [ + loadProviderModels, + modelPickerFavorites, + modelPickerRecents, + modelState.modelId, + modelState.provider, + models, + refreshAiSetupStatus, + setPaneFocus, + ], + ); + + const toggleModelPickerFavoriteId = useCallback( + (modelId: string) => { + if (!modelId) return; + // Optimistic toggle so the UI updates instantly. + setModelPickerFavorites((prev) => + prev.includes(modelId) ? prev.filter((entry) => entry !== modelId) : [...prev, modelId], + ); + const conn = connectionRef.current; + if (!conn) return; + void toggleModelPickerFavorite(conn, modelId) + .then((result) => { + if (Array.isArray(result.favorites)) setModelPickerFavorites(result.favorites); + }) + .catch(() => undefined); + }, + [], + ); + useEffect(() => { const range = activeMentionRange; const conn = connectionRef.current; @@ -4635,7 +4733,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (name === "/model") { - openModelRow(); + openModelPicker(); return; } if (name === "/info") { @@ -5145,7 +5243,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (name === "/model") { - openModelRow(); + openModelPicker(); return; } if (name === "/info") { @@ -5840,6 +5938,66 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }); }, [scheduleModelStateCommit]); + // Commit a model picked in the right-pane ModelPicker into the current chat + // model state and push it onto the cross-surface recents list. Defined here + // (after applyModelState) so the closure captures a live binding. + const commitModelPickerSelection = useCallback( + (modelId: string) => { + const target = models.find((entry) => (entry.modelId ?? entry.id) === modelId); + if (!target) { + addNotice(`Model ${modelId} is not available right now.`, "error"); + return; + } + const descriptor = getModelById(modelId); + const provider: AdeCodeProvider = descriptor + ? normalizeProvider(resolveProviderGroupForModel(descriptor)) + : modelState.provider; + applyModelState((prev) => ({ + ...prev, + ...modelStatePatchForModel(provider, target), + codexFastMode: modelSupportsFastMode(descriptor) ? prev.codexFastMode : false, + })); + setModelPickerRecents((prev) => { + const filtered = prev.filter((entry) => entry !== modelId); + return [modelId, ...filtered].slice(0, 10); + }); + const conn = connectionRef.current; + if (conn) { + void pushModelPickerRecent(conn, modelId) + .then((recents) => setModelPickerRecents(recents)) + .catch(() => undefined); + } + // If we were picking for a new-chat draft, return to the setup pane so + // the user can finish configuring and dispatch. Otherwise close the pane. + let restoreSetup = false; + setRightPane((prev) => { + if (prev.kind === "model-picker" && prev.surface === "new-chat") { + const laneId = activeLaneIdRef.current; + const lane = laneId ? lanes.find((entry) => entry.id === laneId) : null; + if (lane) { + restoreSetup = true; + return { + kind: "new-chat-setup", + laneId: lane.id, + laneLabel: lane.name, + rows: newChatSetupRows, + }; + } + } + return { kind: "empty" }; + }); + if (restoreSetup) { + setRightOpen(true); + setPaneFocus("details"); + } else { + setRightOpen(false); + setPaneFocus("chat"); + } + addNotice(`Model set to ${target.displayName}.`, "success"); + }, + [addNotice, applyModelState, lanes, models, modelState.provider, newChatSetupRows, setPaneFocus], + ); + const selectProvider = useCallback((provider: AdeCodeProvider) => { if (providerLockedRef.current) { addNotice("Provider is locked for this chat. /new chat to switch.", "info"); @@ -6150,7 +6308,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return true; } if (action === "chat:modelPicker") { - openModelRow(); + openModelPicker(); return true; } if (action === "chat:fastMode") { @@ -6984,6 +7142,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (key.return) { + // Enter on the model row opens the rich picker (favorites/recents/providers). + // Other rows still fall through to "apply" for parity with the prior flow. + const focusedRow = rows[rightSelectionIndex]; + if (focusedRow?.kind === "model" && !focusedRow.disabled) { + openModelPicker({ surface: "new-chat" }); + return; + } const applyRow = rows.find((entry) => entry.kind === "apply"); if (applyRow) handleSetupRow(applyRow, 1); return; @@ -6997,6 +7162,124 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } + if (pane === "details" && rightOpen && rightPane.kind === "model-picker") { + const picker = rightPane; + // Re-derive layout each keystroke so we never select stale indexes. + const layout = buildModelPickerLayout({ + models, + favorites: modelPickerFavorites, + recents: modelPickerRecents, + activeModelId: modelState.modelId, + query: picker.query, + selection: picker.selection, + focusedIndex: picker.focusedIndex, + searchMode: picker.searchMode, + }); + + if (key.escape) { + if (picker.query.length) { + setRightPane({ ...picker, query: "", searchMode: false, focusedIndex: 0 }); + return; + } + if (picker.surface === "new-chat") { + const laneId = activeLaneIdRef.current; + const lane = laneId ? lanes.find((entry) => entry.id === laneId) : null; + if (lane) { + setRightPane({ + kind: "new-chat-setup", + laneId: lane.id, + laneLabel: lane.name, + rows: newChatSetupRows, + }); + setRightOpen(true); + setPaneFocus("details"); + return; + } + } + setRightPane({ kind: "empty" }); + setRightOpen(false); + setPaneFocus("chat"); + return; + } + if (key.upArrow) { + const next = Math.max(0, layout.focusedIndex - 1); + setRightPane({ ...picker, focusedIndex: next }); + return; + } + if (key.downArrow) { + const next = Math.min(Math.max(0, layout.entries.length - 1), layout.focusedIndex + 1); + setRightPane({ ...picker, focusedIndex: next }); + return; + } + if (key.tab || (key.shift && key.tab)) { + const total = layout.railEntries.length; + if (total === 0) return; + const delta = key.shift ? -1 : 1; + const nextIndex = (layout.railIndex + delta + total) % total; + const nextEntry = layout.railEntries[nextIndex]; + if (!nextEntry) return; + const nextSelection = + nextEntry.kind === "favorites" + ? ({ kind: "favorites" } as const) + : nextEntry.kind === "recents" + ? ({ kind: "recents" } as const) + : ({ kind: "provider", provider: nextEntry.provider } as const); + setRightPane({ + ...picker, + selection: nextSelection, + focusedIndex: 0, + query: "", + searchMode: false, + }); + return; + } + if (key.return) { + const target = layout.entries[layout.focusedIndex]; + if (target) commitModelPickerSelection(target.modelId); + return; + } + // 'f' toggles favorite on focused row when not actively editing a search. + if (input === "f" && !picker.searchMode && !key.ctrl && !key.meta) { + const target = layout.entries[layout.focusedIndex]; + if (target) toggleModelPickerFavoriteId(target.modelId); + return; + } + // '/' enters search mode — clears any previous query. + if (input === "/" && !picker.searchMode) { + setRightPane({ ...picker, searchMode: true, query: "", focusedIndex: 0 }); + return; + } + // Backspace shortens the active query; if empty, exit search mode. + if (key.backspace || key.delete) { + if (!picker.searchMode && !picker.query.length) return; + const nextQuery = picker.query.slice(0, -1); + setRightPane({ + ...picker, + query: nextQuery, + searchMode: nextQuery.length > 0, + focusedIndex: 0, + }); + return; + } + // Plain printable input either starts or extends the query. + if ( + typeof input === "string" + && input.length === 1 + && !key.ctrl + && !key.meta + && input >= " " + ) { + setRightPane({ + ...picker, + query: picker.query + input, + searchMode: true, + focusedIndex: 0, + }); + return; + } + return; + } + if (pane === "details" && rightOpen && rightPane.kind === "lane-details") { const laneDetails = rightPane; const worktreeMissing = laneDetails.worktreeAvailable === false; @@ -7498,6 +7781,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } focused={activePane === "details"} activeProvider={activeCommandProvider as AdeCodeProvider} width={rightPaneWidth} + modelPickerInputs={{ + models, + favorites: modelPickerFavorites, + recents: modelPickerRecents, + activeModelId: modelState.modelId, + }} /> ) : null} diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx b/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx new file mode 100644 index 000000000..8ba6b99f8 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx @@ -0,0 +1,223 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { theme } from "../../theme"; +import type { AdeCodeProvider } from "../../types"; +import type { ModelPickerEntry, ModelPickerRailEntry, ModelPickerState } from "./types"; + +const PROVIDER_GLYPHS: Record = { + codex: "◇", + claude: "✦", + opencode: "○", + cursor: "◈", + droid: "▲", +}; + +function railGlyph(entry: ModelPickerRailEntry): string { + if (entry.kind === "favorites") return "★"; + if (entry.kind === "recents") return "◷"; + return PROVIDER_GLYPHS[entry.provider] ?? "·"; +} + +function endTruncate(value: string, max: number): string { + if (max <= 1) return value.length ? "…" : ""; + if (value.length <= max) return value; + return `${value.slice(0, Math.max(0, max - 1))}…`; +} + +function ModelPickerSearchBar({ + query, + searchMode, + width, +}: { + query: string; + searchMode: boolean; + width: number; +}) { + const displayed = endTruncate(query || "search models…", Math.max(8, width - 4)); + return ( + + {searchMode ? "▸" : "/"} + + + {displayed} + + + ); +} + +function ModelPickerRail({ + entries, + selectedIndex, + width, +}: { + entries: ModelPickerRailEntry[]; + selectedIndex: number; + width: number; +}) { + const labelWidth = Math.max(6, Math.min(14, width - 4)); + return ( + + {entries.map((entry, index) => { + const selected = index === selectedIndex; + return ( + + + {selected ? theme.rail : " "} + + + {" "} + {railGlyph(entry)}{" "} + + + {endTruncate(entry.label, labelWidth)} + + + ); + })} + + ); +} + +function ModelListRow({ + entry, + selected, + active, + width, +}: { + entry: ModelPickerEntry; + selected: boolean; + active: boolean; + width: number; +}) { + const labelMax = Math.max(8, width - 4); + const starColor = entry.isFavorite ? theme.color.warning : theme.color.t5; + return ( + + + + {selected ? theme.rail : " "} + + + {entry.isFavorite ? " ★" : " ☆"} + + + {" "} + {endTruncate(entry.displayName, labelMax)} + + {active ? ( + + {" "} + ·{" "}now + + ) : null} + + {entry.subProvider ? ( + + + {endTruncate(entry.subProvider, labelMax - 2)} + + + ) : null} + + ); +} + +const VISIBLE_ROW_BUDGET = 12; + +function rowWindow(rowCount: number, selected: number, capacity: number): { start: number; end: number } { + if (rowCount <= capacity) return { start: 0, end: rowCount }; + const half = Math.floor(capacity / 2); + let start = Math.max(0, selected - half); + let end = start + capacity; + if (end > rowCount) { + end = rowCount; + start = end - capacity; + } + return { start, end }; +} + +export function ModelPickerPane({ + state, + width, +}: { + state: ModelPickerState; + width: number; +}) { + const innerWidth = Math.max(20, width - 4); + const railEntry = state.railEntries[state.railIndex] ?? state.railEntries[0]; + const headingLabel = state.query.trim() + ? "Search results" + : railEntry?.kind === "favorites" + ? "Favorites" + : railEntry?.kind === "recents" + ? "Recents" + : (railEntry?.label ?? "Models"); + + const window = rowWindow(state.entries.length, state.focusedIndex, VISIBLE_ROW_BUDGET); + const visibleEntries = state.entries.slice(window.start, window.end); + const hiddenBefore = window.start; + const hiddenAfter = state.entries.length - window.end; + + return ( + + + + + + + + + {headingLabel} + + {state.entries.length === 0 ? ( + + {state.query.trim() + ? "No models match your search." + : railEntry?.kind === "favorites" + ? "Press f on a model to pin it here." + : railEntry?.kind === "recents" + ? "Models you switch to will appear here." + : "No models available."} + + ) : ( + <> + {hiddenBefore > 0 ? ( + {` ↑ ${hiddenBefore} earlier`} + ) : null} + {visibleEntries.map((entry, sliceIndex) => { + const flatIndex = window.start + sliceIndex; + const selected = flatIndex === state.focusedIndex; + const active = state.activeModelId != null && entry.modelId === state.activeModelId; + return ( + + ); + })} + {hiddenAfter > 0 ? ( + {` ↓ ${hiddenAfter} more`} + ) : null} + + )} + + + + + + ↑↓ pick · ↵ select · tab rail · f fav · / search · esc close + + + + ); +} diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.test.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.test.ts new file mode 100644 index 000000000..0ed3e1bb2 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import type { AgentChatModelInfo } from "../../../../../desktop/src/shared/types/chat"; +import { buildModelPickerLayout, defaultSelectionFor } from "./modelPickerLayout"; + +function modelInfo(overrides: Partial & { id: string }): AgentChatModelInfo { + return { + displayName: overrides.displayName ?? overrides.id, + isDefault: false, + ...overrides, + }; +} + +describe("buildModelPickerLayout", () => { + const models: AgentChatModelInfo[] = [ + modelInfo({ id: "anthropic/claude-opus-4-7", displayName: "Claude Opus 4.7" }), + modelInfo({ id: "anthropic/claude-sonnet-4-6", displayName: "Claude Sonnet 4.6" }), + modelInfo({ id: "openai/gpt-5", displayName: "GPT-5" }), + ]; + + it("emits favorites + recents + provider rails", () => { + const layout = buildModelPickerLayout({ + models, + favorites: [], + recents: [], + activeModelId: null, + query: "", + selection: { kind: "favorites" }, + focusedIndex: 0, + searchMode: false, + }); + expect(layout.railEntries[0]?.kind).toBe("favorites"); + expect(layout.railEntries[1]?.kind).toBe("recents"); + expect(layout.railEntries.some((entry) => entry.kind === "provider")).toBe(true); + }); + + it("scopes the entry list to the selected provider", () => { + const layout = buildModelPickerLayout({ + models, + favorites: [], + recents: [], + activeModelId: null, + query: "", + selection: { kind: "provider", provider: "codex" }, + focusedIndex: 0, + searchMode: false, + }); + expect(layout.entries.every((entry) => entry.family === "codex")).toBe(true); + }); + + it("orders recents by insertion order", () => { + const layout = buildModelPickerLayout({ + models, + favorites: [], + recents: ["openai/gpt-5", "anthropic/claude-opus-4-7"], + activeModelId: null, + query: "", + selection: { kind: "recents" }, + focusedIndex: 0, + searchMode: false, + }); + expect(layout.entries.map((entry) => entry.modelId)).toEqual([ + "openai/gpt-5", + "anthropic/claude-opus-4-7", + ]); + }); + + it("treats a non-empty query as cross-provider search", () => { + const layout = buildModelPickerLayout({ + models, + favorites: [], + recents: [], + activeModelId: null, + query: "opus", + selection: { kind: "provider", provider: "codex" }, + focusedIndex: 0, + searchMode: true, + }); + expect(layout.entries.length).toBeGreaterThan(0); + expect(layout.entries[0]?.displayName.toLowerCase()).toContain("opus"); + }); + + it("marks favorites on the resulting entries", () => { + const layout = buildModelPickerLayout({ + models, + favorites: ["anthropic/claude-opus-4-7"], + recents: [], + activeModelId: null, + query: "", + selection: { kind: "provider", provider: "claude" }, + focusedIndex: 0, + searchMode: false, + }); + const opus = layout.entries.find((entry) => entry.modelId === "anthropic/claude-opus-4-7"); + expect(opus?.isFavorite).toBe(true); + }); + + it("clamps focusedIndex into the visible range", () => { + const layout = buildModelPickerLayout({ + models, + favorites: [], + recents: [], + activeModelId: null, + query: "", + selection: { kind: "provider", provider: "claude" }, + focusedIndex: 99, + searchMode: false, + }); + expect(layout.focusedIndex).toBe(Math.max(0, layout.entries.length - 1)); + }); +}); + +describe("defaultSelectionFor", () => { + it("prefers recents when present", () => { + const layout = buildModelPickerLayout({ + models: [], + favorites: [], + recents: ["openai/gpt-5"], + activeModelId: null, + query: "", + selection: { kind: "favorites" }, + focusedIndex: 0, + searchMode: false, + }); + const selection = defaultSelectionFor(null, ["openai/gpt-5"], layout.railEntries); + expect(selection.kind).toBe("recents"); + }); + + it("falls back to the first provider rail when no recents and no active model", () => { + const layout = buildModelPickerLayout({ + models: [modelInfo({ id: "openai/gpt-5" })], + favorites: [], + recents: [], + activeModelId: null, + query: "", + selection: { kind: "favorites" }, + focusedIndex: 0, + searchMode: false, + }); + const selection = defaultSelectionFor(null, [], layout.railEntries); + expect(selection.kind).toBe("provider"); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts new file mode 100644 index 000000000..1b005a834 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts @@ -0,0 +1,205 @@ +import { scoreModelPickerSearch } from "../../../../../desktop/src/renderer/components/shared/ModelPicker/modelPickerSearch"; +import { sortModelItems } from "../../../../../desktop/src/renderer/components/shared/ModelPicker/modelOrdering"; +import type { AgentChatModelInfo } from "../../../../../desktop/src/shared/types/chat"; +import { + getModelById, + resolveProviderGroupForModel, + type ModelDescriptor, + type ProviderFamily, +} from "../../../../../desktop/src/shared/modelRegistry"; +import type { AdeCodeProvider } from "../../types"; +import type { + ModelPickerEntry, + ModelPickerRailEntry, + ModelPickerState, +} from "./types"; + +const PROVIDER_LABELS: Record = { + codex: "OpenAI", + claude: "Anthropic", + opencode: "OpenCode", + cursor: "Cursor", + droid: "Droid", +}; + +function providerLabel(provider: AdeCodeProvider): string { + return PROVIDER_LABELS[provider] ?? provider; +} + +function normalizeProvider(value: ProviderFamily | string | undefined): AdeCodeProvider { + // resolveProviderGroupForModel already returns ModelProviderGroup values + // (claude/codex/opencode/cursor/droid). Map ProviderFamily aliases as well so + // raw registry families resolve correctly. + if (value === "claude" || value === "anthropic") return "claude"; + if (value === "codex" || value === "openai") return "codex"; + if (value === "opencode") return "opencode"; + if (value === "cursor") return "cursor"; + if (value === "droid" || value === "factory") return "droid"; + return "codex"; +} + +function descriptorFor(modelInfo: AgentChatModelInfo): ModelDescriptor | undefined { + const id = modelInfo.modelId ?? modelInfo.id; + return getModelById(id); +} + +function entryFromModelInfo( + modelInfo: AgentChatModelInfo, + favoritesSet: Set, +): ModelPickerEntry { + const modelId = modelInfo.modelId ?? modelInfo.id; + const descriptor = descriptorFor(modelInfo); + const provider: AdeCodeProvider = descriptor + ? normalizeProvider(resolveProviderGroupForModel(descriptor)) + : normalizeProvider(modelInfo.family); + const runtimeModelId = descriptor?.providerModelId ?? descriptor?.shortId ?? modelInfo.id; + return { + modelId, + runtimeModelId, + displayName: modelInfo.displayName, + family: provider, + ...(descriptor?.openCodeProviderId + ? { subProvider: `${descriptor.openCodeProviderId} via OpenCode` } + : {}), + isFavorite: favoritesSet.has(modelId), + isAvailable: true, + }; +} + +export type BuildLayoutInput = { + models: AgentChatModelInfo[]; + favorites: string[]; + recents: string[]; + activeModelId: string | null; + query: string; + selection: { kind: "favorites" } | { kind: "recents" } | { kind: "provider"; provider: AdeCodeProvider }; + focusedIndex: number; + searchMode: boolean; +}; + +export function buildModelPickerLayout(input: BuildLayoutInput): ModelPickerState { + const favoritesSet = new Set(input.favorites); + const allEntries = input.models.map((m) => entryFromModelInfo(m, favoritesSet)); + + // Providers actually present in the registry-filtered model list. + const providersPresent = Array.from( + new Set(allEntries.map((entry) => entry.family)), + ); + const railEntries: ModelPickerRailEntry[] = [ + { kind: "favorites", label: "Favorites" }, + { kind: "recents", label: "Recents" }, + ...providersPresent.map((provider) => ({ + kind: "provider" as const, + provider, + label: providerLabel(provider), + })), + ]; + + const trimmedQuery = input.query.trim(); + const searchActive = trimmedQuery.length > 0; + + let pool: ModelPickerEntry[]; + if (searchActive) { + pool = allEntries; + } else if (input.selection.kind === "favorites") { + pool = allEntries.filter((entry) => favoritesSet.has(entry.modelId)); + } else if (input.selection.kind === "recents") { + const recentSet = new Set(input.recents); + const order = new Map(input.recents.map((id, i) => [id, i] as const)); + pool = allEntries + .filter((entry) => recentSet.has(entry.modelId)) + .sort((a, b) => (order.get(a.modelId) ?? 0) - (order.get(b.modelId) ?? 0)); + } else { + const target = input.selection.provider; + pool = allEntries.filter((entry) => entry.family === target); + } + + let entries: ModelPickerEntry[]; + if (searchActive) { + const scored: Array<{ entry: ModelPickerEntry; score: number }> = []; + for (const candidate of pool) { + const score = scoreModelPickerSearch( + { + name: candidate.displayName, + family: (candidate.family === "claude" + ? "anthropic" + : candidate.family === "codex" + ? "openai" + : candidate.family) as ProviderFamily, + providerDisplayName: providerLabel(candidate.family), + isFavorite: candidate.isFavorite, + ...(candidate.subProvider ? { subProvider: candidate.subProvider } : {}), + }, + trimmedQuery, + ); + if (score === null) continue; + scored.push({ entry: candidate, score }); + } + scored.sort((a, b) => a.score - b.score); + entries = scored.map((s) => s.entry); + } else { + const sorted = sortModelItems( + pool.map((entry) => ({ modelId: entry.modelId, _entry: entry })), + { favoriteModelIds: favoritesSet, groupFavorites: true }, + ); + entries = sorted.map((entry) => entry._entry); + } + + // Pick rail index from selection. + let railIndex = 0; + if (input.selection.kind === "favorites") { + railIndex = 0; + } else if (input.selection.kind === "recents") { + railIndex = 1; + } else { + const targetProvider = input.selection.provider; + const idx = railEntries.findIndex( + (entry) => entry.kind === "provider" && entry.provider === targetProvider, + ); + railIndex = idx >= 0 ? idx : 0; + } + + const focusedIndex = entries.length === 0 + ? 0 + : Math.max(0, Math.min(input.focusedIndex, entries.length - 1)); + + return { + query: input.query, + searchMode: input.searchMode, + railEntries, + railIndex, + entries, + focusedIndex, + activeModelId: input.activeModelId, + }; +} + +export function railEntrySelection(entry: ModelPickerRailEntry): + | { kind: "favorites" } + | { kind: "recents" } + | { kind: "provider"; provider: AdeCodeProvider } { + if (entry.kind === "favorites") return { kind: "favorites" }; + if (entry.kind === "recents") return { kind: "recents" }; + return { kind: "provider", provider: entry.provider }; +} + +export function defaultSelectionFor( + activeModelId: string | null, + recents: string[], + railEntries: ModelPickerRailEntry[], +): ReturnType { + if (recents.length > 0) return { kind: "recents" }; + if (activeModelId) { + const descriptor = getModelById(activeModelId); + if (descriptor) { + const provider = normalizeProvider(resolveProviderGroupForModel(descriptor)); + const match = railEntries.find( + (entry) => entry.kind === "provider" && entry.provider === provider, + ); + if (match) return railEntrySelection(match); + } + } + const firstProvider = railEntries.find((entry) => entry.kind === "provider"); + if (firstProvider) return railEntrySelection(firstProvider); + return { kind: "favorites" }; +} diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/types.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/types.ts new file mode 100644 index 000000000..71fe7fe37 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/types.ts @@ -0,0 +1,31 @@ +import type { AdeCodeProvider } from "../../types"; + +export type ModelPickerRailKind = "favorites" | "recents" | "provider"; + +export type ModelPickerRailEntry = + | { kind: "favorites"; label: string } + | { kind: "recents"; label: string } + | { kind: "provider"; provider: AdeCodeProvider; label: string }; + +export type ModelPickerEntry = { + /** Canonical ADE model id (matches modelRegistry.id). Empty string for placeholder. */ + modelId: string; + /** Provider/runtime model ref (for selection commit). */ + runtimeModelId: string; + displayName: string; + family: AdeCodeProvider; + /** Optional sub-provider label (e.g. "anthropic via OpenCode"). */ + subProvider?: string; + isFavorite: boolean; + isAvailable: boolean; +}; + +export type ModelPickerState = { + query: string; + searchMode: boolean; + railEntries: ModelPickerRailEntry[]; + railIndex: number; + entries: ModelPickerEntry[]; + focusedIndex: number; + activeModelId: string | null; +}; diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index 9623f3837..8c3e6e247 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -10,6 +10,9 @@ import type { import type { AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; import { theme } from "../theme"; import { buildSubagentPaneRows, type SubagentPaneRow } from "../subagentPane"; +import { ModelPickerPane } from "./ModelPicker/ModelPickerPane"; +import { buildModelPickerLayout } from "./ModelPicker/modelPickerLayout"; +import type { AgentChatModelInfo } from "../../../../desktop/src/shared/types/chat"; // --------------------------------------------------------------------------- // Right-pane width / focus chrome @@ -656,6 +659,8 @@ function paneTitle(content: RightPaneContent): { title: string; hint?: string; b return { title: "MODEL" }; case "chat-info": return { title: `CHAT INFO · ${theme.provider(content.info.provider).label.toUpperCase()}` }; + case "model-picker": + return { title: content.surface === "new-chat" ? "MODEL · NEW CHAT" : "MODEL" }; case "help": return { title: "HELP" }; case "status": @@ -684,6 +689,7 @@ export function RightPane({ selectedIndex = 0, focused = false, width = DEFAULT_PANE_WIDTH, + modelPickerInputs, }: { content: RightPaneContent; formValues?: Record; @@ -692,6 +698,13 @@ export function RightPane({ focused?: boolean; activeProvider?: AdeCodeProvider | null; width?: number; + /** Data passed in by app.tsx for the model-picker content kind. */ + modelPickerInputs?: { + models: AgentChatModelInfo[]; + favorites: string[]; + recents: string[]; + activeModelId: string | null; + }; }) { const { title, hint, branch } = paneTitle(content); const paneWidth = Math.max(30, width); @@ -786,12 +799,26 @@ export function RightPane({ ) : null} - {content.kind === "new-chat-setup" || content.kind === "model-setup" ? ( + {content.kind === "model-picker" && modelPickerInputs ? ( + + ) : null} + + {content.kind === "new-chat-setup" ? ( - {content.kind === "new-chat-setup" ? ( - Lane: {content.laneLabel} - ) : null} - + Lane: {content.laneLabel} + {content.rows.map((row, index) => { const selected = index === selectedIndex; return ( @@ -809,7 +836,7 @@ export function RightPane({ ); })} - ↑↓ rows · ←→ change · ↵ {content.kind === "model-setup" ? "done" : "prompt"} · cmd+↵ background + ↑↓ rows · ←→ change · ↵ prompt · cmd+↵ background ) : null} diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index ab4e9e4a8..154658622 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -148,8 +148,24 @@ export type ChatInfoSnapshot = { streaming: boolean; }; +export type ModelPickerRightPaneSelection = + | { kind: "favorites" } + | { kind: "recents" } + | { kind: "provider"; provider: AdeCodeProvider }; + +export type ModelPickerRightPaneContent = { + kind: "model-picker"; + /** Active surface (chat) we're committing the picked model into. */ + surface: "chat" | "new-chat"; + query: string; + searchMode: boolean; + selection: ModelPickerRightPaneSelection; + focusedIndex: number; +}; + export type RightPaneContent = | { kind: "empty" } + | ModelPickerRightPaneContent | { kind: "help"; title: string } | { kind: "status"; rows: Array<[string, string]> } | { diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 51ee6404d..5b6bc3873 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -946,6 +946,15 @@ declare global { args: CursorCloudOpenChatRequest, ) => Promise; }; + modelPicker: { + getFavorites: () => Promise<{ favorites: string[] }>; + setFavorites: (favorites: string[]) => Promise<{ favorites: string[] }>; + toggleFavorite: ( + modelId: string, + ) => Promise<{ favorites: string[]; isFavorite: boolean }>; + getRecents: () => Promise<{ recents: string[] }>; + pushRecent: (modelId: string) => Promise<{ recents: string[] }>; + }; sync: { getStatus: (args?: SyncGetStatusArgs) => Promise; refreshDiscovery: () => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 58b0310d1..47a8fac46 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -3102,6 +3102,27 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.aiCursorCloudOpenChat, args), ), }, + modelPicker: { + getFavorites: async (): Promise<{ favorites: string[] }> => + callProjectRuntimeSyncOr("modelPicker.getFavorites", {}, async () => ({ favorites: [] })), + setFavorites: async (favorites: string[]): Promise<{ favorites: string[] }> => + callProjectRuntimeSyncOr("modelPicker.setFavorites", { favorites }, async () => ({ + favorites, + })), + toggleFavorite: async ( + modelId: string, + ): Promise<{ favorites: string[]; isFavorite: boolean }> => + callProjectRuntimeSyncOr("modelPicker.toggleFavorite", { modelId }, async () => ({ + favorites: [], + isFavorite: false, + })), + getRecents: async (): Promise<{ recents: string[] }> => + callProjectRuntimeSyncOr("modelPicker.getRecents", {}, async () => ({ recents: [] })), + pushRecent: async (modelId: string): Promise<{ recents: string[] }> => + callProjectRuntimeSyncOr("modelPicker.pushRecent", { modelId }, async () => ({ + recents: [], + })), + }, sync: { getStatus: async (args?: SyncGetStatusArgs): Promise => callProjectRuntimeSyncOr("sync.getStatus", args ?? {}, () => diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/useModelFavorites.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/useModelFavorites.ts index ce838614a..85b1119b6 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/useModelFavorites.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/useModelFavorites.ts @@ -1,6 +1,11 @@ +import { useEffect } from "react"; import { create } from "zustand"; import { useShallow } from "zustand/react/shallow"; +// Authoritative storage lives in the ade-cli main process so favorites sync +// across desktop, TUI, and iOS surfaces. We keep a localStorage cache so the +// initial render is instant while the RPC roundtrip completes in the +// background, and so the picker still works when the runtime isn't bound. const STORAGE_KEY = "ade.modelPicker.favorites.v1"; function readPersisted(): string[] { @@ -19,18 +24,42 @@ function persist(values: string[]): void { try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(values)); } catch { - // ignore — favorites are convenience state. + // ignore — cache is convenience state. } } +function getRpcApi(): + | { + getFavorites: () => Promise<{ favorites: string[] }>; + toggleFavorite: (id: string) => Promise<{ favorites: string[]; isFavorite: boolean }>; + } + | null { + if (typeof window === "undefined") return null; + const ade = (window as unknown as { ade?: { modelPicker?: unknown } }).ade; + const picker = ade?.modelPicker as + | { + getFavorites?: () => Promise<{ favorites: string[] }>; + toggleFavorite?: (id: string) => Promise<{ favorites: string[]; isFavorite: boolean }>; + } + | undefined; + if (!picker?.getFavorites || !picker.toggleFavorite) return null; + return picker as { + getFavorites: () => Promise<{ favorites: string[] }>; + toggleFavorite: (id: string) => Promise<{ favorites: string[]; isFavorite: boolean }>; + }; +} + type FavoritesState = { favorites: string[]; + hydrated: boolean; toggleFavorite: (modelId: string) => void; isFavorite: (modelId: string) => boolean; + hydrateFromRemote: () => Promise; }; const useFavoritesStore = create((set, get) => ({ favorites: typeof window !== "undefined" ? readPersisted() : [], + hydrated: false, toggleFavorite: (modelId: string) => { const id = modelId.trim(); if (!id) return; @@ -38,20 +67,60 @@ const useFavoritesStore = create((set, get) => ({ const next = current.includes(id) ? current.filter((entry) => entry !== id) : [...current, id]; set({ favorites: next }); persist(next); + const api = getRpcApi(); + if (!api) return; + void api + .toggleFavorite(id) + .then((result) => { + const authoritative = Array.isArray(result?.favorites) ? result.favorites : null; + if (!authoritative) return; + set({ favorites: authoritative }); + persist(authoritative); + }) + .catch(() => { + // Keep the optimistic update on RPC failure — we'll reconcile on next hydrate. + }); }, isFavorite: (modelId: string) => get().favorites.includes(modelId), + hydrateFromRemote: async () => { + const api = getRpcApi(); + if (!api) { + set({ hydrated: true }); + return; + } + try { + const result = await api.getFavorites(); + const authoritative = Array.isArray(result?.favorites) ? result.favorites : []; + set({ favorites: authoritative, hydrated: true }); + persist(authoritative); + } catch { + set({ hydrated: true }); + } + }, })); +let hydrationStarted = false; + export function useModelFavorites(): { favorites: string[]; toggleFavorite: (modelId: string) => void; isFavorite: (modelId: string) => boolean; } { - return useFavoritesStore( + const { favorites, toggleFavorite, isFavorite, hydrateFromRemote, hydrated } = useFavoritesStore( useShallow((state) => ({ favorites: state.favorites, toggleFavorite: state.toggleFavorite, isFavorite: state.isFavorite, + hydrateFromRemote: state.hydrateFromRemote, + hydrated: state.hydrated, })), ); + + useEffect(() => { + if (hydrationStarted || hydrated) return; + hydrationStarted = true; + void hydrateFromRemote(); + }, [hydrateFromRemote, hydrated]); + + return { favorites, toggleFavorite, isFavorite }; } diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.ts index 8c3bc7532..5758ac9ed 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.ts @@ -1,9 +1,12 @@ +import { useEffect } from "react"; import { create } from "zustand"; import { useShallow } from "zustand/react/shallow"; +// Authoritative storage lives in the ade-cli main process so recents sync +// across desktop, TUI, and iOS surfaces. localStorage is a hot cache that +// keeps the picker responsive while the RPC roundtrip completes. const STORAGE_KEY = "ade.modelPicker.recents.v1"; const MAX_RECENTS = 10; -const PERSIST_DEBOUNCE_MS = 500; function readPersisted(): string[] { try { @@ -19,32 +22,45 @@ function readPersisted(): string[] { } } -let persistTimer: ReturnType | null = null; -let pendingValues: string[] | null = null; +function persistCache(values: string[]): void { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(values)); + } catch { + // ignore — cache is convenience state. + } +} -function schedulePersist(values: string[]): void { - pendingValues = values; - if (persistTimer != null) return; - persistTimer = setTimeout(() => { - persistTimer = null; - const toWrite = pendingValues; - pendingValues = null; - if (toWrite == null) return; - try { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(toWrite)); - } catch { - // ignore — recents are convenience state. +function getRpcApi(): + | { + getRecents: () => Promise<{ recents: string[] }>; + pushRecent: (id: string) => Promise<{ recents: string[] }>; } - }, PERSIST_DEBOUNCE_MS); + | null { + if (typeof window === "undefined") return null; + const ade = (window as unknown as { ade?: { modelPicker?: unknown } }).ade; + const picker = ade?.modelPicker as + | { + getRecents?: () => Promise<{ recents: string[] }>; + pushRecent?: (id: string) => Promise<{ recents: string[] }>; + } + | undefined; + if (!picker?.getRecents || !picker.pushRecent) return null; + return picker as { + getRecents: () => Promise<{ recents: string[] }>; + pushRecent: (id: string) => Promise<{ recents: string[] }>; + }; } type RecentsState = { recents: string[]; + hydrated: boolean; recordUsage: (modelId: string) => void; + hydrateFromRemote: () => Promise; }; const useRecentsStore = create((set, get) => ({ recents: typeof window !== "undefined" ? readPersisted() : [], + hydrated: false, recordUsage: (modelId: string) => { const id = modelId.trim(); if (!id) return; @@ -52,20 +68,59 @@ const useRecentsStore = create((set, get) => ({ const filtered = current.filter((entry) => entry !== id); const next = [id, ...filtered].slice(0, MAX_RECENTS); set({ recents: next }); - schedulePersist(next); + persistCache(next); + const api = getRpcApi(); + if (!api) return; + void api + .pushRecent(id) + .then((result) => { + const authoritative = Array.isArray(result?.recents) ? result.recents : null; + if (!authoritative) return; + set({ recents: authoritative }); + persistCache(authoritative); + }) + .catch(() => { + // Optimistic update is preserved; we'll reconcile on next hydrate. + }); + }, + hydrateFromRemote: async () => { + const api = getRpcApi(); + if (!api) { + set({ hydrated: true }); + return; + } + try { + const result = await api.getRecents(); + const authoritative = Array.isArray(result?.recents) ? result.recents : []; + set({ recents: authoritative, hydrated: true }); + persistCache(authoritative); + } catch { + set({ hydrated: true }); + } }, })); +let hydrationStarted = false; + export function useModelRecents(): { recents: string[]; recordUsage: (modelId: string) => void; recordRecent: (modelId: string) => void; } { - return useRecentsStore( + const { recents, recordUsage, hydrateFromRemote, hydrated } = useRecentsStore( useShallow((state) => ({ recents: state.recents, recordUsage: state.recordUsage, - recordRecent: state.recordUsage, + hydrateFromRemote: state.hydrateFromRemote, + hydrated: state.hydrated, })), ); + + useEffect(() => { + if (hydrationStarted || hydrated) return; + hydrationStarted = true; + void hydrateFromRemote(); + }, [hydrateFromRemote, hydrated]); + + return { recents, recordUsage, recordRecent: recordUsage }; } From 45a4d39335e662c4bcaf37d6e46476b1b373304e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 18 May 2026 05:18:55 -0400 Subject: [PATCH 03/14] Fix droid slash commands + TUI default model resolution - agentChatService.getSlashCommands now returns /clear plus filesystem-discovered Claude + Codex prompt commands for droid sessions, mirroring codex. Previously droid sessions fell through to the OpenCode/Cursor branch and returned an empty list. - adeApi.ts droid default model now resolves from the model registry via getDefaultModelDescriptor("droid") instead of a hardcoded string. Tests added for both lane-only and live-session droid slash command paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/chat/agentChatService.test.ts | 59 +++++++++++++++++++ .../main/services/chat/agentChatService.ts | 22 +++++++ 2 files changed, 81 insertions(+) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index f11c4be6b..82d9aaf6b 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -4173,6 +4173,65 @@ describe("createAgentChatService", () => { expect(clearCmd!.source).toBe("local"); }); + it("returns Claude and Codex prompt commands plus /clear for a droid lane", async () => { + const claudeCommandsDir = path.join(tmpRoot, ".claude", "commands"); + fs.mkdirSync(claudeCommandsDir, { recursive: true }); + fs.writeFileSync(path.join(claudeCommandsDir, "deploy.md"), [ + "---", + "description: Deploy the active branch", + "---", + "", + "Deploy.", + "", + ].join("\n")); + const codexPromptsDir = path.join(tmpRoot, ".codex", "prompts"); + fs.mkdirSync(codexPromptsDir, { recursive: true }); + fs.writeFileSync(path.join(codexPromptsDir, "triage.md"), "Triage the inbox."); + const { service } = createService(); + + const commands = service.getSlashCommands({ laneId: "lane-1", provider: "droid" }); + const names = commands.map((command) => command.name); + + expect(names).toContain("/clear"); + expect(commands).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "/deploy", + description: "Deploy the active branch", + source: "sdk", + }), + expect.objectContaining({ + name: "/triage", + description: "Triage the inbox.", + source: "sdk", + }), + ])); + }); + + it("returns the same slash command set for a live droid session", async () => { + const codexPromptsDir = path.join(tmpRoot, ".codex", "prompts"); + fs.mkdirSync(codexPromptsDir, { recursive: true }); + fs.writeFileSync(path.join(codexPromptsDir, "summarize.md"), "Summarize this lane."); + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "droid", + model: "custom:claude-sonnet-4-6-thinking-32000", + modelId: "droid/custom:claude-sonnet-4-6-thinking-32000", + }); + + const commands = service.getSlashCommands({ sessionId: session.id }); + const names = commands.map((command) => command.name); + + expect(names).toContain("/clear"); + expect(commands).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "/summarize", + description: "Summarize this lane.", + source: "sdk", + }), + ])); + }); + it("does not advertise /login as a Claude SDK command", async () => { const { service } = createService(); const session = await service.createSession({ diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 1de3ed8ae..680890e85 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -20828,6 +20828,28 @@ export function createAgentChatService(args: { return mergeSlashCommands([promptCommands, claudeProjectCommands, CODEX_BUILT_IN_SLASH_COMMANDS, dynamicCommands]); } + // Droid uses Claude/Codex models under the hood, so surface the same + // filesystem-backed prompt commands codex exposes (Claude `.claude/commands` + // and Codex `.codex/prompts`) plus a local `/clear` for chat housekeeping. + if (provider === "droid") { + const promptCommands: AgentChatSlashCommand[] = discoverCodexSlashCommands(laneWorktreePath) + .map((cmd) => ({ + name: cmd.name, + description: cmd.description, + argumentHint: cmd.argumentHint, + source: "sdk" as const, + })); + const claudeProjectCommands: AgentChatSlashCommand[] = discoverClaudeSlashCommands(laneWorktreePath) + .filter(isDispatchableClaudeSdkSlashCommand) + .map((cmd) => ({ + name: cmd.name, + description: cmd.description, + argumentHint: cmd.argumentHint, + source: "sdk" as const, + })); + return mergeSlashCommands([promptCommands, claudeProjectCommands, localCommands]); + } + // OpenCode / Cursor — only local commands return localCommands; }; From 8217a9b6aa18d91008be5b3689d6f0ad9e0b7218 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 18 May 2026 05:19:09 -0400 Subject: [PATCH 04/14] iOS ModelPicker overhaul + droidPermissionMode round-trip WorkModelPickerSheet rebuilt to match desktop ModelPicker layout: - Vertical rail (Favorites star, Recents clock, divider, per-provider entries) - Right content pane with section header, search, grouped model rows - Per-row favorite star + active-checkmark + reasoning pills - Optimistic favorites/recents store with rollback on RPC error - Liquid glass styling via ADEColor.glassBorder + recessedBackground SyncService.swift gains 5 RPC methods consuming the cross-surface favorites/recents contract: modelPicker.getFavorites / setFavorites / toggleFavorite / getRecents / pushRecent Chat session structs (AgentChatSession, AgentChatSessionSummary, AgentChatUpdateSessionRequest) now round-trip droidPermissionMode through createChatSession + updateChatSession, matching the desktop schema. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ios/ADE/Models/RemoteModels.swift | 37 + apps/ios/ADE/Services/SyncService.swift | 60 + .../ADE/Views/Work/WorkModelPickerSheet.swift | 1053 ++++++++++------- apps/ios/ADE/Views/Work/WorkPreviews.swift | 2 + apps/ios/ADETests/ADETests.swift | 1 + 5 files changed, 751 insertions(+), 402 deletions(-) diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 3502f8907..5fd21c57a 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -559,6 +559,7 @@ struct AgentChatSessionSummary: Codable, Identifiable, Equatable { var codexSandbox: String? var codexConfigSource: String? var opencodePermissionMode: String? + var droidPermissionMode: String? var cursorModeSnapshot: RemoteJSONValue? var cursorModeId: String? var cursorConfigValues: [String: RemoteJSONValue]? @@ -1083,6 +1084,7 @@ struct AgentChatSession: Codable, Identifiable, Equatable { var codexSandbox: String? var codexConfigSource: String? var opencodePermissionMode: String? + var droidPermissionMode: String? var cursorModeSnapshot: RemoteJSONValue? var cursorModeId: String? var cursorConfigValues: [String: RemoteJSONValue]? @@ -1120,6 +1122,7 @@ struct AgentChatSession: Codable, Identifiable, Equatable { case codexSandbox case codexConfigSource case opencodePermissionMode + case droidPermissionMode case cursorModeSnapshot case cursorModeId case cursorConfigValues @@ -1159,6 +1162,7 @@ struct AgentChatSession: Codable, Identifiable, Equatable { codexSandbox = try container.decodeIfPresent(String.self, forKey: .codexSandbox) codexConfigSource = try container.decodeIfPresent(String.self, forKey: .codexConfigSource) opencodePermissionMode = try container.decodeIfPresent(String.self, forKey: .opencodePermissionMode) + droidPermissionMode = try container.decodeIfPresent(String.self, forKey: .droidPermissionMode) cursorModeSnapshot = try container.decodeIfPresent(RemoteJSONValue.self, forKey: .cursorModeSnapshot) cursorModeId = try container.decodeIfPresent(String.self, forKey: .cursorModeId) cursorConfigValues = try container.decodeIfPresent([String: RemoteJSONValue].self, forKey: .cursorConfigValues) @@ -1197,6 +1201,7 @@ struct AgentChatSession: Codable, Identifiable, Equatable { try container.encodeIfPresent(codexSandbox, forKey: .codexSandbox) try container.encodeIfPresent(codexConfigSource, forKey: .codexConfigSource) try container.encodeIfPresent(opencodePermissionMode, forKey: .opencodePermissionMode) + try container.encodeIfPresent(droidPermissionMode, forKey: .droidPermissionMode) try container.encodeIfPresent(cursorModeSnapshot, forKey: .cursorModeSnapshot) try container.encodeIfPresent(cursorModeId, forKey: .cursorModeId) try container.encodeIfPresent(cursorConfigValues, forKey: .cursorConfigValues) @@ -1836,6 +1841,7 @@ struct AgentChatUpdateSessionRequest: Codable, Equatable { var codexSandbox: String? var codexConfigSource: String? var opencodePermissionMode: String? + var droidPermissionMode: String? var cursorModeId: String? var cursorConfigValues: [String: RemoteJSONValue]? var unifiedPermissionMode: String? @@ -1939,6 +1945,37 @@ struct AgentChatModelCatalog: Codable, Equatable { var fetchedAt: String } +/// Response envelopes for the cross-surface ModelPicker favorites/recents +/// RPC. Each method returns its own keyed wrapper (`{ favorites: [...] }` for +/// favorites methods, `{ recents: [...] }` for recents methods, plus +/// `toggleFavorite` adds an `isFavorite` boolean). Persistence lives at +/// `~/.ade/modelPicker.json` on the ade-cli host; `MAX_RECENTS = 10`. +struct ModelPickerFavorites: Codable, Equatable { + var favorites: [String] + + init(favorites: [String] = []) { + self.favorites = favorites + } +} + +struct ModelPickerToggleFavoriteResult: Codable, Equatable { + var favorites: [String] + var isFavorite: Bool + + init(favorites: [String] = [], isFavorite: Bool = false) { + self.favorites = favorites + self.isFavorite = isFavorite + } +} + +struct ModelPickerRecents: Codable, Equatable { + var recents: [String] + + init(recents: [String] = []) { + self.recents = recents + } +} + struct LaneListSnapshot: Codable, Identifiable, Equatable { var id: String { lane.id } var lane: LaneSummary diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 75c921324..bf95bd6ed 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -3698,6 +3698,60 @@ final class SyncService: ObservableObject { ].joined(separator: "\u{1f}") } + // MARK: - Cross-surface model picker (favorites + recents) + // + // Mirrors the desktop `useModelFavorites` / `useModelRecents` hooks and the + // TUI implementation. Backed by `~/.ade/modelPicker.json` on the ade-cli + // host so favorites and recents follow the user across worktrees, projects, + // and surfaces (desktop, TUI, iOS). Recents are capped at 10 server-side + // — the client should not pre-trim. + + func getModelFavorites() async throws -> [String] { + let payload = try await sendDecodableCommand( + action: "modelPicker.getFavorites", + args: [:], + as: ModelPickerFavorites.self + ) + return payload.favorites + } + + func setModelFavorites(_ favorites: [String]) async throws -> [String] { + let payload = try await sendDecodableCommand( + action: "modelPicker.setFavorites", + args: ["favorites": favorites], + as: ModelPickerFavorites.self + ) + return payload.favorites + } + + @discardableResult + func toggleModelFavorite(_ modelId: String) async throws -> ModelPickerToggleFavoriteResult { + try await sendDecodableCommand( + action: "modelPicker.toggleFavorite", + args: ["modelId": modelId], + as: ModelPickerToggleFavoriteResult.self + ) + } + + func getModelRecents() async throws -> [String] { + let payload = try await sendDecodableCommand( + action: "modelPicker.getRecents", + args: [:], + as: ModelPickerRecents.self + ) + return payload.recents + } + + @discardableResult + func pushModelRecent(_ modelId: String) async throws -> [String] { + let payload = try await sendDecodableCommand( + action: "modelPicker.pushRecent", + args: ["modelId": modelId], + as: ModelPickerRecents.self + ) + return payload.recents + } + func listChatSessions(laneId: String) async throws -> [AgentChatSessionSummary] { try await sendDecodableCommand(action: "chat.listSessions", args: ["laneId": laneId, "includeAutomation": true], as: [AgentChatSessionSummary].self) } @@ -3716,6 +3770,7 @@ final class SyncService: ObservableObject { codexSandbox: String? = nil, codexConfigSource: String? = nil, opencodePermissionMode: String? = nil, + droidPermissionMode: String? = nil, cursorModeId: String? = nil, cursorConfigValues: [String: RemoteJSONValue]? = nil, computerUse: RemoteJSONValue? = nil, @@ -3760,6 +3815,9 @@ final class SyncService: ObservableObject { if let opencodePermissionMode, !opencodePermissionMode.isEmpty { args["opencodePermissionMode"] = opencodePermissionMode } + if let droidPermissionMode, !droidPermissionMode.isEmpty { + args["droidPermissionMode"] = droidPermissionMode + } if let cursorModeId, !cursorModeId.isEmpty { args["cursorModeId"] = cursorModeId } @@ -3887,6 +3945,7 @@ final class SyncService: ObservableObject { codexSandbox: String? = nil, codexConfigSource: String? = nil, opencodePermissionMode: String? = nil, + droidPermissionMode: String? = nil, cursorModeId: String? = nil, cursorConfigValues: [String: RemoteJSONValue]? = nil, unifiedPermissionMode: String? = nil, @@ -3908,6 +3967,7 @@ final class SyncService: ObservableObject { codexSandbox: codexSandbox, codexConfigSource: codexConfigSource, opencodePermissionMode: opencodePermissionMode, + droidPermissionMode: droidPermissionMode, cursorModeId: cursorModeId, cursorConfigValues: cursorConfigValues, unifiedPermissionMode: unifiedPermissionMode, diff --git a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift index b692f2d1c..0f38a0207 100644 --- a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift @@ -1,11 +1,15 @@ import SwiftUI -/// Mobile model picker — desktop-shaped 2-level organization. Mirrors -/// `apps/desktop/src/renderer/components/shared/ModelCatalogPanel.tsx`: -/// "Select Model" header with search, CLAUDE / CODEX / CURSOR / OPENCODE -/// group tab strip, provider badge row for the active group (Anthropic for -/// Claude, or Anthropic/OpenAI/Google/… for OpenCode), then the models in -/// the selected provider. +/// Mobile model picker — desktop-shaped Favorites / Recents / Providers layout. +/// Mirrors `apps/desktop/src/renderer/components/shared/ModelPicker/`: a +/// vertical rail on the leading edge picks the section (Favorites, Recents, +/// or a provider family), and the trailing pane shows the matching model +/// rows with a search field on top. +/// +/// Favorites and recents are sourced from the cross-surface RPC contract +/// (`modelPicker.getFavorites` / `getRecents` / `toggleFavorite` / +/// `pushRecent`) so the same starred and recently-used models follow the user +/// between the desktop, the TUI, and the iOS app. struct WorkModelPickerSheet: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject private var syncService: SyncService @@ -30,12 +34,13 @@ struct WorkModelPickerSheet: View { self.onSelect = onSelect } - @State private var activeGroup: String = "" - @State private var activeProvider: String = "" + @StateObject private var picker = ModelPickerStore() + @State private var selection: ModelPickerRailSelection = .favorites @State private var searchText: String = "" @State private var liveCatalog: [WorkModelCatalogGroup]? @State private var isLoadingCatalog = false @State private var usingCuratedFallback = false + @State private var didPickInitialSelection = false private var curatedCatalog: [WorkModelCatalogGroup] { workModelCatalogGroups(currentModelId: currentModelId, currentProvider: currentProvider) @@ -48,63 +53,39 @@ struct WorkModelPickerSheet: View { return usingCuratedFallback ? curatedCatalog : [] } - private var catalogIdentity: String { - catalog.map { group in - "\(group.key):" + group.providers.map { provider in - "\(provider.key):\(provider.models.map(\.id).joined(separator: ","))" - }.joined(separator: "|") - }.joined(separator: "||") - } - - private var isSearching: Bool { - !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + private var flattenedModels: [WorkModelOption] { + var seen = Set() + var out: [WorkModelOption] = [] + for group in catalog { + for provider in group.providers { + for model in provider.models where seen.insert(model.id).inserted { + out.append(model) + } + } + } + return out } - private var activeGroupBlock: WorkModelCatalogGroup? { - catalog.first(where: { $0.key == activeGroup }) ?? catalog.first + private var modelById: [String: WorkModelOption] { + Dictionary(uniqueKeysWithValues: flattenedModels.map { ($0.id, $0) }) } - private var activeProviderBlock: WorkModelProvider? { - guard let block = activeGroupBlock else { return nil } - return block.providers.first(where: { $0.key == activeProvider }) ?? block.providers.first + /// Rail entries: Favorites + Recents first, then one row per provider that + /// has at least one model in the active catalog. + private var railEntries: [ModelPickerRailEntry] { + var entries: [ModelPickerRailEntry] = [.favorites, .recents] + for group in catalog { + entries.append(.providerGroup(key: group.key, label: groupLabel(group))) + } + return entries } - private var filteredModels: [WorkModelOption] { - guard let provider = activeProviderBlock else { return [] } - let needle = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !needle.isEmpty else { return provider.models } - return provider.models.filter { - $0.displayName.lowercased().contains(needle) || - $0.id.lowercased().contains(needle) || - $0.tagline.lowercased().contains(needle) - } + private var isSearching: Bool { + !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - /// Flat search result — when a query is active we ignore group/provider - /// tabs and show every matching model, grouped by group header like the - /// desktop search mode. - private var searchTree: [WorkModelCatalogGroup] { - let needle = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !needle.isEmpty else { return [] } - return catalog.compactMap { group in - let filteredProviders = group.providers.compactMap { provider -> WorkModelProvider? in - let matches = provider.models.filter { - $0.displayName.lowercased().contains(needle) || - $0.id.lowercased().contains(needle) || - $0.tagline.lowercased().contains(needle) - } - return matches.isEmpty ? nil : WorkModelProvider( - key: provider.key, - displayName: provider.displayName, - models: matches - ) - } - return filteredProviders.isEmpty ? nil : WorkModelCatalogGroup( - key: group.key, - displayName: group.displayName, - providers: filteredProviders - ) - } + private var hasCatalog: Bool { + !catalog.isEmpty } var body: some View { @@ -115,13 +96,31 @@ struct WorkModelPickerSheet: View { loadingState } else if catalog.isEmpty { catalogEmptyState - } else if isSearching { - searchList } else { - groupTabStrip - providerBadgeRow - Divider().overlay(ADEColor.border.opacity(0.18)) - modelList + Divider().overlay(ADEColor.glassBorder) + HStack(spacing: 0) { + ModelPickerRail( + entries: railEntries, + selected: effectiveSelection, + favoritesCount: picker.favorites.count, + recentsCount: picker.recents.count, + onSelect: { selection = $0 } + ) + Divider().overlay(ADEColor.glassBorder) + ModelPickerContentPane( + selection: effectiveSelection, + isSearching: isSearching, + searchText: searchText, + models: visibleModels, + groupedRows: groupedRows, + currentModelId: currentModelId, + currentReasoningEffort: currentReasoningEffort, + favorites: picker.favorites, + isBusy: isBusy, + onSelect: { model, effort in commit(model: model, effort: effort) }, + onToggleFavorite: { picker.toggleFavorite($0, syncService: syncService) } + ) + } } } .adeScreenBackground() @@ -139,120 +138,125 @@ struct WorkModelPickerSheet: View { } } } - .presentationDetents([.medium, .large]) + .presentationDetents([.large]) .presentationDragIndicator(.visible) .onAppear { - syncSelectionStateToCatalog() - } - .onChange(of: catalogIdentity) { _, _ in - syncSelectionStateToCatalog() - } - .onChange(of: activeGroup) { _, newKey in - if let block = catalog.first(where: { $0.key == newKey }) { - activeProvider = preferredProviderKey(in: block) - } + picker.load(syncService: syncService) } .task(id: "\(currentModelId)\u{0}\(currentProvider)") { await loadLiveCatalog() + pickInitialSelectionIfNeeded() + } + } + + /// Falls back to the first available provider entry only when the user's + /// last-picked provider group has disappeared from the catalog (e.g. the + /// host removed it). Favorites/Recents always reflect the user's choice + /// even when empty — the empty-state hint nudges them to star or pick a + /// model rather than silently swapping their section. + private var effectiveSelection: ModelPickerRailSelection { + if isSearching { return selection } + switch selection { + case .favorites, .recents: + return selection + case .providerGroup(let key, _): + if catalog.contains(where: { $0.key == key }) { + return selection + } + return firstProviderSelection() ?? .favorites } } - @MainActor - private func syncSelectionStateToCatalog() { - guard !catalog.isEmpty else { return } - if let location = catalogLocation(for: currentModelId) { - activeGroup = location.groupKey - activeProvider = location.providerKey - return - } + private func firstProviderSelection() -> ModelPickerRailSelection? { + guard let first = catalog.first else { return nil } + return .providerGroup(key: first.key, label: groupLabel(first)) + } - let targetGroupKey = workModelCatalogGroupKey(for: currentModelId, currentProvider: currentProvider) - if activeGroup.isEmpty || !catalog.contains(where: { $0.key == activeGroup }) { - activeGroup = catalog.first(where: { $0.key == targetGroupKey })?.key - ?? catalog.first?.key - ?? "" + private func pickInitialSelectionIfNeeded() { + guard hasCatalog, !didPickInitialSelection else { return } + didPickInitialSelection = true + if let activeGroupKey = catalogGroupContaining(modelId: currentModelId) { + let label = catalog.first(where: { $0.key == activeGroupKey }).map(groupLabel) ?? activeGroupKey + selection = .providerGroup(key: activeGroupKey, label: label) + return } - if activeProvider.isEmpty || activeProviderBlock == nil, let block = activeGroupBlock { - activeProvider = preferredProviderKey(in: block) + if !picker.recents.isEmpty { + selection = .recents + return } + selection = firstProviderSelection() ?? .favorites } - @MainActor - private func loadLiveCatalog() async { - isLoadingCatalog = true - defer { - isLoadingCatalog = false - syncSelectionStateToCatalog() + /// When the user types in the search box every section behaves like a flat + /// "all models" list; the rail selection becomes informational only. + private var visibleModels: [WorkModelOption] { + let needle = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let pool: [WorkModelOption] + if isSearching { + pool = flattenedModels + } else { + switch effectiveSelection { + case .favorites: + let lookup = modelById + pool = picker.favorites.compactMap { lookup[$0] } + case .recents: + let lookup = modelById + pool = picker.recents.compactMap { lookup[$0] } + case .providerGroup(let key, _): + pool = catalog.first(where: { $0.key == key })?.providers.flatMap { $0.models } ?? [] + } } - usingCuratedFallback = false - if liveCatalog == nil, let cached = syncService.cachedChatModelCatalog() { - liveCatalog = workModelCatalogGroups( - hostCatalog: cached, - currentModelId: currentModelId, - currentProvider: currentProvider - ) - syncSelectionStateToCatalog() + guard !needle.isEmpty else { return pool } + return pool.filter { model in + model.displayName.lowercased().contains(needle) || + model.id.lowercased().contains(needle) || + model.tagline.lowercased().contains(needle) } + } - do { - let hostCatalog = try await syncService.getChatModelCatalog() - guard !Task.isCancelled else { return } - liveCatalog = workModelCatalogGroups( - hostCatalog: hostCatalog, - currentModelId: currentModelId, - currentProvider: currentProvider - ) - usingCuratedFallback = false - } catch { - guard !Task.isCancelled else { return } - if liveCatalog == nil { - usingCuratedFallback = true + /// For provider sections we split models by sub-provider (Anthropic vs + /// OpenAI vs Google inside OpenCode, etc.) so the rendered list mirrors the + /// desktop "sub-header per provider" layout. Search results collapse to a + /// single flat list to match the desktop's search-active mode. + private var groupedRows: [ModelPickerRowGroup] { + if isSearching { + return [ModelPickerRowGroup(id: "_search", title: nil, models: visibleModels)] + } + switch effectiveSelection { + case .favorites, .recents: + return [ModelPickerRowGroup(id: "_root", title: nil, models: visibleModels)] + case .providerGroup(let key, _): + guard let group = catalog.first(where: { $0.key == key }) else { + return [ModelPickerRowGroup(id: "_root", title: nil, models: visibleModels)] + } + let providers = group.providers + // Single-provider groups (Claude, Codex) skip sub-headers entirely. + if providers.count <= 1 { + return [ModelPickerRowGroup(id: "_only", title: nil, models: visibleModels)] + } + return providers.compactMap { provider -> ModelPickerRowGroup? in + guard !provider.models.isEmpty else { return nil } + return ModelPickerRowGroup( + id: provider.key, + title: provider.displayName, + models: provider.models + ) } } } - private func catalogLocation(for modelId: String) -> (groupKey: String, providerKey: String)? { + private func catalogGroupContaining(modelId: String) -> String? { for group in catalog { for provider in group.providers { if provider.models.contains(where: { workModelIdsEquivalent($0.id, modelId) }) { - return (group.key, provider.key) + return group.key } } } return nil } - private func preferredProviderKey(in block: WorkModelCatalogGroup) -> String { - if block.key == "opencode", - let providerKey = opencodeProviderKey(from: currentModelId), - let provider = block.providers.first(where: { $0.key == providerKey }) { - return provider.key - } - - if let provider = block.providers.first(where: { provider in - provider.models.contains { workModelIdsEquivalent($0.id, currentModelId) } - }) { - return provider.key - } - - let lower = currentProvider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if let provider = block.providers.first(where: { $0.key == lower }) { - return provider.key - } - - return block.providers.first?.key ?? "" - } - - private func opencodeProviderKey(from modelId: String) -> String? { - let parts = modelId - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .split(separator: "/", omittingEmptySubsequences: true) - guard parts.count >= 3, parts[0] == "opencode" else { return nil } - return String(parts[1]) - } - private func runtimeProvider(for model: WorkModelOption) -> String { if let group = catalog.first(where: { group in group.providers.contains { provider in @@ -264,24 +268,48 @@ struct WorkModelPickerSheet: View { return workModelCatalogGroupKey(for: model.id, currentProvider: currentProvider) } - private func supportedReasoningTiers(for model: WorkModelOption) -> [String] { - var seen = Set() - return model.reasoningEfforts.compactMap { effort in - let tier = effort.effort.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !tier.isEmpty, seen.insert(tier).inserted else { return nil } - return tier - } + private func groupLabel(_ group: WorkModelCatalogGroup) -> String { + group.displayName } - private func reasoningLabel(for tier: String) -> String { - switch tier.lowercased() { - case "xhigh": return "XHigh" - case "max": return "Max" - default: return tier.capitalized + // MARK: Subviews + + @ViewBuilder + private var searchBar: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.subheadline) + .foregroundStyle(ADEColor.textMuted) + TextField("Search models…", text: $searchText) + .textFieldStyle(.plain) + .font(.subheadline) + .foregroundStyle(ADEColor.textPrimary) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + if !searchText.isEmpty { + Button { + searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.subheadline) + .foregroundStyle(ADEColor.textMuted) + } + .buttonStyle(.plain) + .accessibilityLabel("Clear search") + } } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background(ADEColor.recessedBackground.opacity(0.55), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(ADEColor.glassBorder, lineWidth: 0.5) + ) + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 10) } - @ViewBuilder private var loadingState: some View { VStack(spacing: 12) { @@ -316,287 +344,444 @@ struct WorkModelPickerSheet: View { .frame(maxWidth: .infinity) } - @ViewBuilder - private var searchBar: some View { - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .font(.subheadline) - .foregroundStyle(ADEColor.textMuted) - TextField("Search models…", text: $searchText) - .textFieldStyle(.plain) - .font(.subheadline) - .foregroundStyle(ADEColor.textPrimary) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - if !searchText.isEmpty { - Button { - searchText = "" - } label: { - Image(systemName: "xmark.circle.fill") - .font(.subheadline) - .foregroundStyle(ADEColor.textMuted) - } - .buttonStyle(.plain) - .accessibilityLabel("Clear search") + // MARK: Behavior + + @MainActor + private func loadLiveCatalog() async { + isLoadingCatalog = true + defer { isLoadingCatalog = false } + usingCuratedFallback = false + + if liveCatalog == nil, let cached = syncService.cachedChatModelCatalog() { + liveCatalog = workModelCatalogGroups( + hostCatalog: cached, + currentModelId: currentModelId, + currentProvider: currentProvider + ) + } + + do { + let hostCatalog = try await syncService.getChatModelCatalog() + guard !Task.isCancelled else { return } + liveCatalog = workModelCatalogGroups( + hostCatalog: hostCatalog, + currentModelId: currentModelId, + currentProvider: currentProvider + ) + usingCuratedFallback = false + } catch { + guard !Task.isCancelled else { return } + if liveCatalog == nil { + usingCuratedFallback = true } } - .padding(.horizontal, 12) - .padding(.vertical, 9) - .background(ADEColor.recessedBackground.opacity(0.55), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(ADEColor.border.opacity(0.2), lineWidth: 0.5) - ) - .padding(.horizontal, 16) - .padding(.top, 12) - .padding(.bottom, 10) } - @ViewBuilder - private var groupTabStrip: some View { - HStack(spacing: 4) { - ForEach(catalog) { group in - groupTabButton(for: group) - } + private func commit(model: WorkModelOption, effort: String?) { + let normalizedEffort = effort? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "" + let normalizedCurrentEffort = currentReasoningEffort + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let nextEffort: String? = normalizedEffort.isEmpty ? nil : normalizedEffort + let effortChanged = (nextEffort ?? "") != normalizedCurrentEffort + let isNoOp = workModelIdsEquivalent(model.id, currentModelId) && !effortChanged + + if isNoOp { + dismiss() + return } - .padding(.horizontal, 4) - .padding(.vertical, 4) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(ADEColor.surfaceBackground.opacity(0.3)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(ADEColor.border.opacity(0.12), lineWidth: 0.5) - ) - .padding(.horizontal, 16) - .padding(.bottom, 10) + + picker.pushRecent(model.id, syncService: syncService) + onSelect(model, nextEffort, runtimeProvider(for: model)) } +} - @ViewBuilder - private func groupTabButton(for group: WorkModelCatalogGroup) -> some View { - let isActive = (activeGroupBlock?.key ?? "") == group.key - Button { - withAnimation(.easeInOut(duration: 0.18)) { - activeGroup = group.key - } - } label: { - HStack(spacing: 4) { - Text(groupTabTitle(for: group)) - .font(.caption2.weight(.bold)) - .tracking(0.4) - .lineLimit(1) - .minimumScaleFactor(0.7) - if group.key == "opencode" && group.modelCount > 0 { - Text("(\(group.modelCount))") - .font(.system(size: 9, weight: .bold)) - .opacity(0.6) - } - } - .foregroundStyle(isActive ? ADEColor.textPrimary : ADEColor.textSecondary.opacity(0.6)) - .frame(maxWidth: .infinity) - .padding(.vertical, 7) - .background( - RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(isActive ? ADEColor.accent.opacity(0.18) : Color.clear) - ) - .overlay( - RoundedRectangle(cornerRadius: 9, style: .continuous) - .stroke(isActive ? ADEColor.accent.opacity(0.35) : Color.clear, lineWidth: 0.6) - ) +// MARK: - Rail + +enum ModelPickerRailSelection: Equatable { + case favorites + case recents + case providerGroup(key: String, label: String) + + static func == (lhs: ModelPickerRailSelection, rhs: ModelPickerRailSelection) -> Bool { + switch (lhs, rhs) { + case (.favorites, .favorites): return true + case (.recents, .recents): return true + case (.providerGroup(let lk, _), .providerGroup(let rk, _)): return lk == rk + default: return false } - .buttonStyle(.plain) - .accessibilityAddTraits(isActive ? .isSelected : []) } +} - private func groupTabTitle(for group: WorkModelCatalogGroup) -> String { - group.key == "opencode" ? "OPEN" : group.displayName.uppercased() +enum ModelPickerRailEntry: Identifiable, Equatable { + case favorites + case recents + case providerGroup(key: String, label: String) + + var id: String { + switch self { + case .favorites: return "_favorites" + case .recents: return "_recents" + case .providerGroup(let key, _): return "provider:\(key)" + } } - @ViewBuilder - private var providerBadgeRow: some View { - if let block = activeGroupBlock, !singleFamilyGroup(block.key), - block.providers.count > 1 || block.key == "opencode" { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(block.providers) { prov in - providerBadge(prov) + var selection: ModelPickerRailSelection { + switch self { + case .favorites: return .favorites + case .recents: return .recents + case .providerGroup(let key, let label): return .providerGroup(key: key, label: label) + } + } +} + +struct ModelPickerRail: View { + let entries: [ModelPickerRailEntry] + let selected: ModelPickerRailSelection + let favoritesCount: Int + let recentsCount: Int + let onSelect: (ModelPickerRailSelection) -> Void + + var body: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .center, spacing: 4) { + ForEach(entries) { entry in + VStack(spacing: 4) { + railButton(entry) + if case .recents = entry { + Divider() + .overlay(ADEColor.glassBorder) + .frame(width: 28) + .padding(.vertical, 4) + } } } - .padding(.horizontal, 16) - .padding(.bottom, 10) } + .padding(.vertical, 8) + .padding(.horizontal, 6) } - } - - /// Groups whose entries all come from a single brand (Claude, Codex) don't - /// need a redundant filter row beneath the group tab — every model is from - /// that brand by definition. - private func singleFamilyGroup(_ key: String) -> Bool { - key == "claude" || key == "codex" + .frame(width: 56) + .background(ADEColor.recessedBackground.opacity(0.4)) } @ViewBuilder - private func providerBadge(_ prov: WorkModelProvider) -> some View { - let isActive = activeProviderBlock?.key == prov.key + private func railButton(_ entry: ModelPickerRailEntry) -> some View { + let isActive = entry.selection == selected Button { - activeProvider = prov.key + onSelect(entry.selection) } label: { - HStack(spacing: 6) { - WorkProviderLogo(provider: prov.key, size: 16) - Text(prov.displayName) - .font(.caption.weight(.semibold)) - .foregroundStyle(isActive ? ADEColor.textPrimary : ADEColor.textSecondary) - if prov.models.count > 1 { - Text("\(prov.models.count)") - .font(.caption2.weight(.bold)) - .foregroundStyle(isActive ? ADEColor.accent : ADEColor.textMuted) - .padding(.horizontal, 5) + ZStack(alignment: .topTrailing) { + railIcon(for: entry, isActive: isActive) + .frame(width: 44, height: 44) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(isActive ? ADEColor.accent.opacity(0.16) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(isActive ? ADEColor.accent.opacity(0.35) : Color.clear, lineWidth: 0.8) + ) + if let badge = badgeCount(for: entry), badge > 0 { + Text("\(badge)") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(ADEColor.textPrimary) + .padding(.horizontal, 4) .padding(.vertical, 1) - .background((isActive ? ADEColor.accent : ADEColor.textMuted).opacity(0.18), in: Capsule()) + .background( + Capsule(style: .continuous) + .fill(ADEColor.surfaceBackground) + ) + .overlay( + Capsule(style: .continuous) + .stroke(ADEColor.glassBorder, lineWidth: 0.5) + ) + .offset(x: 6, y: -4) } } - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background( - Capsule(style: .continuous) - .fill(isActive ? ADEColor.accent.opacity(0.14) : ADEColor.surfaceBackground.opacity(0.5)) - ) - .overlay( - Capsule(style: .continuous) - .stroke(isActive ? ADEColor.accent.opacity(0.32) : ADEColor.border.opacity(0.18), lineWidth: 0.6) - ) } .buttonStyle(.plain) + .accessibilityLabel(accessibilityLabel(for: entry)) .accessibilityAddTraits(isActive ? .isSelected : []) } - @ViewBuilder - private var modelList: some View { - ScrollView { - LazyVStack(spacing: 10) { - if filteredModels.isEmpty { - emptyState - } else { - ForEach(filteredModels) { model in - modelButton(model: model) - } - } - } - .padding(.horizontal, 16) - .padding(.vertical, 12) + private func badgeCount(for entry: ModelPickerRailEntry) -> Int? { + switch entry { + case .favorites: return favoritesCount + case .recents: return recentsCount + case .providerGroup: return nil } } @ViewBuilder - private var searchList: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 14) { - if searchTree.isEmpty { - VStack(spacing: 6) { - Image(systemName: "magnifyingglass") - .font(.title3) - .foregroundStyle(ADEColor.textMuted) - Text("No models match \"\(searchText)\".") - .font(.footnote) - .foregroundStyle(ADEColor.textSecondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 40) - } else { - ForEach(searchTree) { group in - VStack(alignment: .leading, spacing: 8) { - Text(group.displayName.uppercased()) - .font(.caption2.weight(.bold)) - .tracking(0.4) - .foregroundStyle(ADEColor.textMuted) - .padding(.horizontal, 6) - ForEach(group.providers) { prov in - VStack(alignment: .leading, spacing: 6) { - if group.providers.count > 1 { - HStack(spacing: 6) { - WorkProviderLogo(provider: prov.key, size: 14) - Text(prov.displayName) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textSecondary) - } - .padding(.horizontal, 6) - } - ForEach(prov.models) { model in - modelButton(model: model) - } + private func railIcon(for entry: ModelPickerRailEntry, isActive: Bool) -> some View { + switch entry { + case .favorites: + Image(systemName: isActive ? "star.fill" : "star") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(ADEColor.warning) + case .recents: + Image(systemName: isActive ? "clock.fill" : "clock") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(isActive ? ADEColor.accent : ADEColor.textSecondary) + case .providerGroup(let key, _): + WorkProviderLogo(provider: providerKeyForLogo(key), size: 30) + } + } + + /// The rail rows show a per-family logo. For the curated "claude"/"codex" + /// groups the brand asset key is the same as the group key, but the desktop + /// catalog uses keys like "opencode" / "cursor" / "droid" which already + /// resolve to brand assets via the existing `providerAssetName` map. + private func providerKeyForLogo(_ key: String) -> String { + key + } + + private func accessibilityLabel(for entry: ModelPickerRailEntry) -> String { + switch entry { + case .favorites: return "Favorites (\(favoritesCount))" + case .recents: return "Recents (\(recentsCount))" + case .providerGroup(_, let label): return label + } + } +} + +// MARK: - Content pane + +struct ModelPickerRowGroup: Identifiable { + let id: String + let title: String? + let models: [WorkModelOption] +} + +struct ModelPickerContentPane: View { + let selection: ModelPickerRailSelection + let isSearching: Bool + let searchText: String + let models: [WorkModelOption] + let groupedRows: [ModelPickerRowGroup] + let currentModelId: String + let currentReasoningEffort: String + let favorites: [String] + let isBusy: Bool + let onSelect: (WorkModelOption, String?) -> Void + let onToggleFavorite: (String) -> Void + + private var favoritesSet: Set { Set(favorites) } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider().overlay(ADEColor.glassBorder) + if groupedRows.allSatisfy({ $0.models.isEmpty }) { + emptyState + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 14) { + ForEach(groupedRows) { group in + VStack(alignment: .leading, spacing: 6) { + if let title = group.title { + Text(title.uppercased()) + .font(.caption2.weight(.bold)) + .tracking(0.4) + .foregroundStyle(ADEColor.textMuted) + .padding(.horizontal, 4) + .padding(.top, 2) + } + ForEach(group.models) { model in + ModelPickerListRow( + model: model, + isActive: workModelIdsEquivalent(model.id, currentModelId), + isFavorite: favoritesSet.contains(model.id), + isBusy: isBusy, + currentReasoningEffort: currentReasoningEffort, + onSelect: { effort in onSelect(model, effort) }, + onToggleFavorite: { onToggleFavorite(model.id) } + ) } } } } + .padding(.horizontal, 14) + .padding(.vertical, 14) } } - .padding(.horizontal, 16) - .padding(.top, 10) - .padding(.bottom, 12) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + @ViewBuilder + private var header: some View { + HStack(alignment: .center, spacing: 8) { + Image(systemName: headerSystemImage) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(headerTint) + Text(headerTitle) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Spacer() + if !models.isEmpty { + Text("\(models.count)") + .font(.caption.weight(.bold)) + .foregroundStyle(ADEColor.textMuted) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule(style: .continuous) + .fill(ADEColor.recessedBackground.opacity(0.5)) + ) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + + private var headerTitle: String { + if isSearching { + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + return "Search · \"\(trimmed)\"" + } + switch selection { + case .favorites: return "Favorites" + case .recents: return "Recents" + case .providerGroup(_, let label): return label + } + } + + private var headerSystemImage: String { + if isSearching { return "magnifyingglass" } + switch selection { + case .favorites: return "star.fill" + case .recents: return "clock.fill" + case .providerGroup: return "cpu" + } + } + + private var headerTint: Color { + if isSearching { return ADEColor.textSecondary } + switch selection { + case .favorites: return ADEColor.warning + case .recents: return ADEColor.accent + case .providerGroup: return ADEColor.textSecondary } } @ViewBuilder private var emptyState: some View { - VStack(spacing: 6) { - Image(systemName: "cpu") - .font(.title3) + VStack(spacing: 8) { + Spacer(minLength: 24) + Image(systemName: emptyImage) + .font(.title3.weight(.semibold)) .foregroundStyle(ADEColor.textMuted) - Text("No models in this provider.") + Text(emptyTitle) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text(emptyHint) .font(.footnote) .foregroundStyle(ADEColor.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + Spacer(minLength: 24) } .frame(maxWidth: .infinity) - .padding(.vertical, 40) } - @ViewBuilder - private func modelButton(model: WorkModelOption) -> some View { - let tiers = supportedReasoningTiers(for: model) - let isSelected = workModelIdsEquivalent(model.id, currentModelId) + private var emptyImage: String { + if isSearching { return "magnifyingglass" } + switch selection { + case .favorites: return "star" + case .recents: return "clock" + case .providerGroup: return "cpu" + } + } + + private var emptyTitle: String { + if isSearching { return "No models match this search." } + switch selection { + case .favorites: return "No favorites yet." + case .recents: return "No recent models." + case .providerGroup: return "No models in this provider." + } + } + + private var emptyHint: String { + if isSearching { + return "Try a different name, family, or model id." + } + switch selection { + case .favorites: + return "Tap the star on any model to pin it here. Favorites sync between desktop, TUI, and mobile." + case .recents: + return "Models you pick here will appear in the recents list, on every paired surface." + case .providerGroup: + return "Sign in to this provider on the paired machine to load its models." + } + } +} + +// MARK: - Row + +struct ModelPickerListRow: View { + let model: WorkModelOption + let isActive: Bool + let isFavorite: Bool + let isBusy: Bool + let currentReasoningEffort: String + let onSelect: (String?) -> Void + let onToggleFavorite: () -> Void + + private var supportedTiers: [String] { + var seen = Set() + return model.reasoningEfforts.compactMap { effort in + let tier = effort.effort.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !tier.isEmpty, seen.insert(tier).inserted else { return nil } + return tier + } + } + + var body: some View { VStack(alignment: .leading, spacing: 0) { Button { - guard tiers.isEmpty else { return } - commit(model: model, effort: nil) + guard supportedTiers.isEmpty else { return } + onSelect(nil) } label: { - modelHeaderRow(model: model, isSelected: isSelected) + headerRow .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) - .disabled(isBusy || !tiers.isEmpty) + .disabled(isBusy || !supportedTiers.isEmpty) - if !tiers.isEmpty { - reasoningPills(model: model, tiers: tiers) - .padding(.top, 2) + if !supportedTiers.isEmpty { + reasoningPills(tiers: supportedTiers) + .padding(.top, 8) } } - .padding(.horizontal, 14) - .padding(.vertical, 12) + .padding(.horizontal, 12) + .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(isSelected ? ADEColor.accent.opacity(0.08) : ADEColor.surfaceBackground.opacity(0.55)) + .fill(isActive ? ADEColor.accent.opacity(0.08) : ADEColor.surfaceBackground.opacity(0.55)) ) .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(isSelected ? ADEColor.accent.opacity(0.35) : ADEColor.border.opacity(0.14), lineWidth: isSelected ? 1 : 0.5) + .stroke(isActive ? ADEColor.accent.opacity(0.35) : ADEColor.glassBorder, lineWidth: isActive ? 1 : 0.5) ) .contentShape(Rectangle()) } @ViewBuilder - private func modelHeaderRow(model: WorkModelOption, isSelected: Bool) -> some View { - HStack(alignment: .center, spacing: 12) { - WorkProviderLogo(provider: model.provider, size: 30) - + private var headerRow: some View { + HStack(alignment: .center, spacing: 10) { + WorkProviderLogo(provider: model.provider, size: 28) VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(model.displayName) .font(.subheadline.weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) .lineLimit(1) - if isSelected { + if isActive { Text("active") .font(.caption2.weight(.bold)) .tracking(0.3) @@ -619,33 +804,34 @@ struct WorkModelPickerSheet: View { .lineLimit(1) } } - Spacer(minLength: 8) - - if isSelected { + favoriteButton + if isActive { Image(systemName: "checkmark") .font(.subheadline.weight(.bold)) .foregroundStyle(ADEColor.accent) - } else { - HStack(spacing: 5) { - Circle() - .fill(ADEColor.success) - .frame(width: 6, height: 6) - Text("Ready") - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.success) - } } } - .accessibilityLabel("\(model.displayName), \(workModelTierLabel(model.tier)). \(model.tagline)\(isSelected ? ". Currently selected." : "")") + .accessibilityLabel("\(model.displayName), \(workModelTierLabel(model.tier)). \(model.tagline)\(isActive ? ". Currently selected." : "")") + } + + @ViewBuilder + private var favoriteButton: some View { + Button { + onToggleFavorite() + } label: { + Image(systemName: isFavorite ? "star.fill" : "star") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(isFavorite ? ADEColor.warning : ADEColor.textMuted) + .frame(width: 30, height: 30) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(isFavorite ? "Remove from favorites" : "Add to favorites") } - /// Reasoning level pill row shown inline under a model card. Tapping a pill - /// commits both the model selection and the chosen effort. Highlights the - /// currently-active effort for the active model so users see what's set. @ViewBuilder - private func reasoningPills(model: WorkModelOption, tiers: [String]) -> some View { - let isActiveModel = workModelIdsEquivalent(model.id, currentModelId) + private func reasoningPills(tiers: [String]) -> some View { let normalizedCurrent = currentReasoningEffort .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() @@ -658,50 +844,113 @@ struct WorkModelPickerSheet: View { HStack(spacing: 5) { ForEach(tiers, id: \.self) { tier in let normalized = tier.lowercased() - let isActive = isActiveModel && normalized == normalizedCurrent + let isActiveTier = isActive && normalized == normalizedCurrent Button { - commit(model: model, effort: normalized) + onSelect(normalized) } label: { Text(reasoningLabel(for: tier)) .font(.caption2.weight(.semibold)) - .foregroundStyle(isActive ? Color.white : ADEColor.textSecondary) + .foregroundStyle(isActiveTier ? Color.white : ADEColor.textSecondary) .lineLimit(1) .padding(.horizontal, 9) .padding(.vertical, 5) .background( Capsule(style: .continuous) - .fill(isActive ? ADEColor.accent : ADEColor.surfaceBackground.opacity(0.6)) + .fill(isActiveTier ? ADEColor.accent : ADEColor.surfaceBackground.opacity(0.6)) ) .overlay( Capsule(style: .continuous) - .stroke(isActive ? ADEColor.accent : ADEColor.border.opacity(0.18), lineWidth: 0.6) + .stroke(isActiveTier ? ADEColor.accent : ADEColor.glassBorder, lineWidth: 0.6) ) } .buttonStyle(.plain) .disabled(isBusy) .accessibilityLabel("\(model.displayName) · reasoning \(reasoningLabel(for: tier))") - .accessibilityAddTraits(isActive ? .isSelected : []) + .accessibilityAddTraits(isActiveTier ? .isSelected : []) } } } Spacer(minLength: 0) } - .padding(.top, 8) } - private func commit(model: WorkModelOption, effort: String?) { - let normalizedEffort = effort? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() ?? "" - let normalizedCurrentEffort = currentReasoningEffort - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - let nextEffort: String? = normalizedEffort.isEmpty ? nil : normalizedEffort - let effortChanged = (nextEffort ?? "") != normalizedCurrentEffort - if workModelIdsEquivalent(model.id, currentModelId) && !effortChanged { - dismiss() - return + private func reasoningLabel(for tier: String) -> String { + switch tier.lowercased() { + case "xhigh": return "XHigh" + case "max": return "Max" + default: return tier.capitalized + } + } +} + +// MARK: - Store + +/// Owns the favorites/recents lists for the picker UI. Optimistic local +/// updates fire immediately so taps feel instant; the RPC sync runs in the +/// background and reconciles when the server responds. Failures roll back to +/// the last known-good list rather than letting the UI diverge silently. +@MainActor +final class ModelPickerStore: ObservableObject { + @Published private(set) var favorites: [String] = [] + @Published private(set) var recents: [String] = [] + @Published private(set) var isLoading: Bool = false + + private var hasLoaded = false + + func load(syncService: SyncService) { + guard !hasLoaded else { return } + hasLoaded = true + isLoading = true + Task { @MainActor [weak self] in + await self?.refresh(syncService: syncService) + } + } + + func refresh(syncService: SyncService) async { + async let favTask = try? await syncService.getModelFavorites() + async let recTask = try? await syncService.getModelRecents() + let fav = await favTask + let rec = await recTask + if let fav { favorites = fav } + if let rec { recents = rec } + isLoading = false + } + + func toggleFavorite(_ modelId: String, syncService: SyncService) { + let previous = favorites + if favorites.contains(modelId) { + favorites.removeAll { $0 == modelId } + } else { + favorites = [modelId] + favorites.filter { $0 != modelId } + } + Task { @MainActor [weak self] in + do { + let result = try await syncService.toggleModelFavorite(modelId) + self?.favorites = result.favorites + } catch { + self?.favorites = previous + } + } + } + + func pushRecent(_ modelId: String, syncService: SyncService) { + let trimmed = modelId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let previous = recents + // Optimistic update: dedupe + cap at server's MAX_RECENTS (10) so the UI + // doesn't briefly flash an over-long list before the server response + // settles. + var next = recents.filter { $0 != trimmed } + next.insert(trimmed, at: 0) + if next.count > 10 { next = Array(next.prefix(10)) } + recents = next + Task { @MainActor [weak self] in + do { + let updated = try await syncService.pushModelRecent(trimmed) + self?.recents = updated + } catch { + self?.recents = previous + } } - onSelect(model, nextEffort, runtimeProvider(for: model)) } } diff --git a/apps/ios/ADE/Views/Work/WorkPreviews.swift b/apps/ios/ADE/Views/Work/WorkPreviews.swift index f51414244..a55de5a49 100644 --- a/apps/ios/ADE/Views/Work/WorkPreviews.swift +++ b/apps/ios/ADE/Views/Work/WorkPreviews.swift @@ -57,6 +57,7 @@ private enum WorkPreviewData { codexSandbox: nil, codexConfigSource: nil, opencodePermissionMode: nil, + droidPermissionMode: nil, cursorModeSnapshot: nil, cursorModeId: nil, cursorConfigValues: nil, @@ -342,6 +343,7 @@ private enum WorkPreviewData { codexSandbox: nil, codexConfigSource: nil, opencodePermissionMode: nil, + droidPermissionMode: nil, cursorModeSnapshot: nil, cursorModeId: nil, cursorConfigValues: nil, diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 3b5cce0b5..4da99ba5d 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -7614,6 +7614,7 @@ final class ADETests: XCTestCase { codexSandbox: nil, codexConfigSource: nil, opencodePermissionMode: nil, + droidPermissionMode: nil, cursorModeSnapshot: nil, cursorModeId: nil, cursorConfigValues: nil, From 47db1e555c2a63e0b493186f92d180ca2ffefdf7 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 18 May 2026 05:25:58 -0400 Subject: [PATCH 05/14] Flush model picker store on process exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pushRecent debounces persistence by 250ms. If the user changes model and closes the TUI immediately, the write hasn't fired yet and the recent is lost. Wire flush() to a one-shot process.exit handler so the pending write lands before the process terminates. Only registered for the production singleton (filePath unset) — tests provide their own path and handle teardown explicitly. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ade-cli/src/services/modelPickerStore.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/ade-cli/src/services/modelPickerStore.ts b/apps/ade-cli/src/services/modelPickerStore.ts index e884b96bb..ee0fa0aac 100644 --- a/apps/ade-cli/src/services/modelPickerStore.ts +++ b/apps/ade-cli/src/services/modelPickerStore.ts @@ -92,6 +92,15 @@ export function createModelPickerStore(options: CreateModelPickerStoreOptions = writePersisted(filePath, { ...state, version: STORE_VERSION }); }; + // Flush pending debounced pushRecent on process exit so the latest model + // selection isn't lost when the user closes the TUI within the debounce window. + // exit handler runs synchronously and flush uses writeFileSync. + if (!options.filePath) { + process.once("exit", () => { + if (persistTimer != null) flush(); + }); + } + return { getFavorites: () => state.favorites.slice(), setFavorites: (favorites) => { From 1206e7d5caa5f5b0fc92e05a9a3523587b2ed0f2 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 18 May 2026 10:58:15 -0400 Subject: [PATCH 06/14] ModelPicker: labeled 'Show all models' toggle + all-providers rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace funnel icon with a visible, labeled toggle ('Show all models') with a switch indicator. Old funnel button had zero affordance — users didn't realize a toggle existed. - When 'Show all models' is on, surface every provider family ADE supports (anthropic, openai, factory/droid, cursor, opencode, ollama, lmstudio) in the rail in a stable order — including dynamic-only providers (opencode, lmstudio) that have no static registry entries. Previously these families silently disappeared because providersPresent was derived only from models present in MODEL_REGISTRY. - Whitelist modelPicker.* methods in runtimeBridge's sync allowlist so desktop's IPC bridge no longer rejects them as 'Local sync method is not exposed'. Known gaps (next PR): Cursor/OpenCode/Droid panes show 'no models match' empty state because they have no registry entries. Claude row currently greys out from useProviderAuthStatus mis-classifying anthropic. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/services/ipc/runtimeBridge.ts | 5 ++ .../shared/ModelPicker/ModelPickerContent.tsx | 58 +++++++++++++++---- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts index 864d54b9a..4e82cdaaf 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -69,6 +69,11 @@ const REMOTE_RUNTIME_SYNC_METHODS = new Set([ "sync.generatePin", "sync.clearPin", "sync.setActiveLanePresence", + "modelPicker.getFavorites", + "modelPicker.setFavorites", + "modelPicker.toggleFavorite", + "modelPicker.getRecents", + "modelPicker.pushRecent", ]); type RuntimeEventWindowSubscription = { diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx index f1df2cd38..511ac6175 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx @@ -7,7 +7,7 @@ import { useRef, useState, } from "react"; -import { MagnifyingGlass, Funnel } from "@phosphor-icons/react"; +import { MagnifyingGlass } from "@phosphor-icons/react"; import { MODEL_REGISTRY, type ModelDescriptor, type ProviderFamily } from "../../../../shared/modelRegistry"; import { cn } from "../../ui/cn"; import { ModelListRow } from "./ModelListRow"; @@ -39,6 +39,19 @@ const PROVIDER_LABELS: Partial> = { factory: "Droid", }; +// Order matters for rail layout — top-tier providers first, then routers, +// then local runtimes. Listed here (not derived from PROVIDER_LABELS) because +// PROVIDER_LABELS may include experimental entries we don't want surfaced. +const ALL_PROVIDER_FAMILIES: readonly ProviderFamily[] = [ + "anthropic", + "openai", + "factory", + "cursor", + "opencode", + "ollama", + "lmstudio", +]; + function providerLabel(family: ProviderFamily): string { return PROVIDER_LABELS[family] ?? family; } @@ -123,8 +136,18 @@ export const ModelPickerContent = memo(function ModelPickerContent({ const providersPresent = useMemo(() => { const set = new Set(); for (const m of expandedModels) set.add(m.family); + if (!authOnly) { + // Show every provider family ADE supports — including dynamic-only + // providers (opencode, lmstudio) that have no static registry entries — + // so the user can see the rail entry + empty state instead of wondering + // why a provider is missing. + for (const family of ALL_PROVIDER_FAMILIES) set.add(family); + // Stabilize rail order so it doesn't flicker as catalog discovery streams in. + return ALL_PROVIDER_FAMILIES.filter((family) => set.has(family)) + .concat([...set].filter((family) => !ALL_PROVIDER_FAMILIES.includes(family))); + } return [...set]; - }, [expandedModels]); + }, [authOnly, expandedModels]); const railEntries = useMemo(() => { const out: RailEntry[] = [{ kind: "favorites" }, { kind: "recents" }]; @@ -462,22 +485,37 @@ export const ModelPickerContent = memo(function ModelPickerContent({ /> From 229a305745ab8fbffd8d50c2dd2fb1b679e879c1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 18 May 2026 11:28:14 -0400 Subject: [PATCH 07/14] Extract reasoning effort into standalone ReasoningEffortPicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull the reasoning effort UI out of the ModelPicker popover and into its own dropdown rendered alongside it in the prompt-box row. The model picker is now purely model selection; reasoning is a sibling control that only appears when the active model has reasoning tiers. - New `ReasoningEffortPicker.tsx` — popover trigger with current tier chip, side='top' (dropup), lists tiers from the descriptor, persists per family via useReasoningByFamily, returns null when no tiers. - Removed showReasoning / reasoningEffort / onReasoningEffortChange props from ModelPicker.tsx and the corresponding render from ModelPickerContent.tsx. Deleted dead ReasoningEffortControl.tsx. - Updated 10 callers across chat composer, chat pane, PR resolver, missions, CTO identity editor, review launch, AI settings, feedback modal, work view, parallel slots. Each renders + side by side. - Mobile (apps/ios) and TUI (apps/ade-cli) intentionally untouched; the user only wanted the split on desktop. - Tests: new ReasoningEffortPicker.test.tsx (8 cases), updated ModelPicker.test.tsx + PrResolverLaunchControls.test.tsx for the new surface area. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/app/FeedbackReporterModal.tsx | 38 +-- .../components/chat/AgentChatComposer.tsx | 59 +++-- .../components/chat/AgentChatPane.tsx | 10 +- .../components/cto/IdentityEditor.tsx | 36 +-- .../components/missions/ModelSelector.tsx | 31 ++- .../shared/PrResolverLaunchControls.test.tsx | 9 +- .../prs/shared/PrResolverLaunchControls.tsx | 10 +- .../components/settings/AiFeaturesSection.tsx | 25 +- .../shared/ModelPicker/ModelPicker.test.tsx | 132 ++++------- .../shared/ModelPicker/ModelPicker.tsx | 59 +---- .../shared/ModelPicker/ModelPickerContent.tsx | 126 ++++------ .../ModelPicker/ReasoningEffortControl.tsx | 72 ------ .../ReasoningEffortPicker.test.tsx | 192 +++++++++++++++ .../ModelPicker/ReasoningEffortPicker.tsx | 218 ++++++++++++++++++ .../shared/ReviewLaunchModelControls.tsx | 28 ++- .../components/terminals/WorkViewArea.tsx | 41 ++-- 16 files changed, 686 insertions(+), 400 deletions(-) delete mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortControl.tsx create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.test.tsx create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx diff --git a/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx b/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx index 094a90606..5f414c97a 100644 --- a/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx +++ b/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx @@ -14,6 +14,7 @@ import { X, } from "@phosphor-icons/react"; import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; +import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; import { useAppStore } from "../../state/appStore"; import { COLORS, MONO_FONT, SANS_FONT } from "../lanes/laneDesignTokens"; import type { AppInfo, ProjectInfo } from "../../../shared/types/core"; @@ -647,22 +648,27 @@ function NewReportTab({
AI assist (optional) - { - setModelId(id); - setReasoningEffort(null); - clearPreparedDraft(); - }} - surfaceKey="feedback-reporter" - availableModelIds={availableModelIds} - showReasoning - reasoningEffort={reasoningEffort} - onReasoningEffortChange={(value) => { - setReasoningEffort(value); - clearPreparedDraft(); - }} - /> +
+ { + setModelId(id); + setReasoningEffort(null); + clearPreparedDraft(); + }} + surfaceKey="feedback-reporter" + availableModelIds={availableModelIds} + onOpenSignIn={openAiProvidersSettings} + /> + { + setReasoningEffort(value); + clearPreparedDraft(); + }} + /> +
{helperText("Leave this empty to build a fully deterministic draft. If you pick a model, ADE only uses it to suggest the title and labels.")}
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index fa15c9b47..f8d3e58f9 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -36,6 +36,7 @@ import { import { getModelById, modelSupportsFastMode } from "../../../shared/modelRegistry"; import { cn } from "../ui/cn"; import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; +import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; import { getPermissionOptions, safetyColors } from "../shared/permissionOptions"; import { CodexTokenInline } from "./codex/CodexTokenInline"; import { ChatAttachmentTray, type ChatAttachmentPendingImage } from "./ChatAttachmentTray"; @@ -3403,17 +3404,24 @@ export function AgentChatComposer({ ) : null} {parallelChatMode && parallelConfiguringIndex != null && parallelModelSlots[parallelConfiguringIndex] ? ( - onParallelSlotModelChange?.(parallelConfiguringIndex, next)} - surfaceKey={`chat-composer-parallel-${parallelConfiguringIndex}`} - {...(availableModelIds ? { availableModelIds } : {})} - disabled={parallelLaunchBusy} - showReasoning - reasoningEffort={parallelModelSlots[parallelConfiguringIndex]!.reasoningEffort} - onReasoningEffortChange={(effort) => onParallelSlotReasoningChange?.(parallelConfiguringIndex, effort)} - compact - /> + <> + onParallelSlotModelChange?.(parallelConfiguringIndex, next)} + surfaceKey={`chat-composer-parallel-${parallelConfiguringIndex}`} + {...(availableModelIds ? { availableModelIds } : {})} + {...(onOpenAiSettings ? { onOpenSignIn: onOpenAiSettings } : {})} + disabled={parallelLaunchBusy} + compact + /> + onParallelSlotReasoningChange?.(parallelConfiguringIndex, effort)} + disabled={parallelLaunchBusy} + compact + /> + ) : null} {parallelChatMode && parallelConfiguringIndex != null && fastModeSupported ? ( ) : null} {!parallelChatMode ? ( - + <> + + + ) : null} {!parallelChatMode && fastModeSupported ? ( ) : null} -
+
+
{handoffTargetProvider ? ( diff --git a/apps/desktop/src/renderer/components/cto/IdentityEditor.tsx b/apps/desktop/src/renderer/components/cto/IdentityEditor.tsx index 40e935b2e..e604d03c0 100644 --- a/apps/desktop/src/renderer/components/cto/IdentityEditor.tsx +++ b/apps/desktop/src/renderer/components/cto/IdentityEditor.tsx @@ -7,6 +7,7 @@ import { Button } from "../ui/Button"; import { cn } from "../ui/cn"; import { cardCls, labelCls, recessedPanelCls, textareaCls } from "./shared/designTokens"; import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; +import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; import { CTO_PERSONALITY_PRESETS, getCtoPersonalityPreset } from "./identityPresets"; import { CtoPromptPreview } from "./CtoPromptPreview"; @@ -167,21 +168,26 @@ export function IdentityEditor({
Model
- setDraft((current) => ({ - ...current, - reasoningEffort: effort, - }))} - onChange={(modelId) => { - setDraft((current) => applyModelSelection(current, modelId)); - setError(null); - }} - /> +
+ { + setDraft((current) => applyModelSelection(current, modelId)); + setError(null); + }} + onOpenSignIn={openAiProvidersSettings} + /> + setDraft((current) => ({ + ...current, + reasoningEffort: effort, + }))} + /> +
{loadingModels ? (
Checking configured models...
) : availableModelIds.length === 0 ? ( diff --git a/apps/desktop/src/renderer/components/missions/ModelSelector.tsx b/apps/desktop/src/renderer/components/missions/ModelSelector.tsx index 044192629..4b0235e55 100644 --- a/apps/desktop/src/renderer/components/missions/ModelSelector.tsx +++ b/apps/desktop/src/renderer/components/missions/ModelSelector.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useMemo } from "react"; import type { ModelConfig, ModelProvider, ThinkingLevel } from "../../../shared/types"; import { getModelById, resolveModelDescriptor } from "../../../shared/modelRegistry"; import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; +import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; type ModelSelectorProps = { value: ModelConfig; @@ -39,7 +40,7 @@ export function ModelSelector({ compact, showRecommendedBadge: _showRecommendedBadge, availableModelIds, - onOpenAiSettings: _onOpenAiSettings, + onOpenAiSettings, surfaceKey = "missions/phase-or-action", }: ModelSelectorProps) { const resolvedModelId = useMemo(() => normalizeModelId(value.modelId), [value.modelId]); @@ -64,16 +65,24 @@ export function ModelSelector({ }); }, [onChange, resolvedModelId]); + const effectiveModelId = selectedDescriptor?.id ?? resolvedModelId; + return ( - +
+ + +
); } diff --git a/apps/desktop/src/renderer/components/prs/shared/PrResolverLaunchControls.test.tsx b/apps/desktop/src/renderer/components/prs/shared/PrResolverLaunchControls.test.tsx index fba402e00..8331c543b 100644 --- a/apps/desktop/src/renderer/components/prs/shared/PrResolverLaunchControls.test.tsx +++ b/apps/desktop/src/renderer/components/prs/shared/PrResolverLaunchControls.test.tsx @@ -7,9 +7,11 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PrAgentPermissionMode } from "../../../../shared/types"; import type { ModelPicker } from "../../shared/ModelPicker/ModelPicker"; +import type { ReasoningEffortPicker } from "../../shared/ModelPicker/ReasoningEffortPicker"; import { PrResolverLaunchControls } from "./PrResolverLaunchControls"; type ModelPickerProps = React.ComponentProps; +type ReasoningPickerProps = React.ComponentProps; vi.mock("../../shared/ModelPicker/ModelPicker", () => ({ ModelPicker: (props: ModelPickerProps) => ( @@ -17,11 +19,16 @@ vi.mock("../../shared/ModelPicker/ModelPicker", () => ({ - {props.reasoningEffort ?? ""}
), })); +vi.mock("../../shared/ModelPicker/ReasoningEffortPicker", () => ({ + ReasoningEffortPicker: (props: ReasoningPickerProps) => ( + {props.reasoningEffort ?? ""} + ), +})); + function renderControls(overrides: { modelId?: string; reasoningEffort?: string; diff --git a/apps/desktop/src/renderer/components/prs/shared/PrResolverLaunchControls.tsx b/apps/desktop/src/renderer/components/prs/shared/PrResolverLaunchControls.tsx index d3d728d3c..caf79e2a8 100644 --- a/apps/desktop/src/renderer/components/prs/shared/PrResolverLaunchControls.tsx +++ b/apps/desktop/src/renderer/components/prs/shared/PrResolverLaunchControls.tsx @@ -8,6 +8,7 @@ import { import type { AiPermissionMode, AgentChatPermissionMode, PrAgentPermissionMode } from "../../../../shared/types"; import { deriveConfiguredModelIds } from "../../../lib/modelOptions"; import { ModelPicker } from "../../shared/ModelPicker/ModelPicker"; +import { ReasoningEffortPicker } from "../../shared/ModelPicker/ReasoningEffortPicker"; import { cn } from "../../ui/cn"; import { getPermissionOptions, safetyColors } from "../../shared/permissionOptions"; @@ -190,9 +191,12 @@ export function PrResolverLaunchControls({ surfaceKey="pr-resolver-launch" availableModelIds={availableModelIds} disabled={disabled} - showReasoning - reasoningEffort={reasoningEffort} - onReasoningEffortChange={(next) => onReasoningEffortChange(next ?? "")} + /> + onReasoningEffortChange(next ?? "")} + disabled={disabled} />
{permissionOptions.map((option) => { diff --git a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx index 69140841d..0fd3d6c2b 100644 --- a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx +++ b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx @@ -15,6 +15,7 @@ import { import { deriveConfiguredModelIds } from "../../lib/modelOptions"; import { getModelById, resolveModelAlias } from "../../../shared/modelRegistry"; import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; +import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; import { ChatCircleDots, GitPullRequest, GitCommit, ChatText, type Icon } from "@phosphor-icons/react"; type FeatureInfo = { @@ -411,16 +412,22 @@ export function AiFeaturesSection() {
-
+
void handleModelChange(feature.key, modelId)} surfaceKey={`ai-feature-${feature.key}`} availableModelIds={availableModelIds} disabled={!enabled} - showReasoning + /> + void handleReasoningChange(feature.key, effort)} + onChange={(effort) => void handleReasoningChange(feature.key, effort)} + disabled={!enabled} />
@@ -508,7 +515,10 @@ export function AiFeaturesSection() {
-
+
{ @@ -518,12 +528,15 @@ export function AiFeaturesSection() { surfaceKey="ai-feature-chat-auto-title" availableModelIds={availableModelIds} disabled={!chatAutoTitleEnabled} - showReasoning + /> + { + onChange={(effort) => { setChatAutoTitleReasoning(effort); void saveChatTitleSettings({ reasoningEffort: effort }); }} + disabled={!chatAutoTitleEnabled} />
diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx index beac01eb5..3f23dca58 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx @@ -301,15 +301,11 @@ describe("ModelPicker", () => { expect(trigger.textContent).toContain(SONNET.displayName); }); - it("renders the reasoning chip on the trigger when showReasoning is set and effort exists", () => { - renderPicker({ - showReasoning: true, - reasoningEffort: "medium", - }); + it("does not render any reasoning chip on the trigger", () => { + renderPicker(); const trigger = screen.getByRole("button", { name: /Select model/i }); const chip = trigger.querySelector('[data-model-picker-reasoning-chip="true"]'); - expect(chip).toBeTruthy(); - expect(chip!.textContent).toContain("MED"); + expect(chip).toBeNull(); }); it("renders the fast-mode toggle outside the trigger when supported", async () => { @@ -352,59 +348,6 @@ describe("ModelPicker", () => { expect(trigger.textContent).toContain(OPUS.displayName); }); - it("remembers reasoning per family and restores when switching families", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - const onReasoningEffortChange = vi.fn(); - // Pre-set memory for openai family - reasoningByFamilyStore.openai = "high"; - render( - , - ); - await user.click(screen.getByRole("button", { name: /Select model/i })); - // Search for the GPT model to make sure it's visible regardless of active rail. - const search = screen.getByLabelText(/Search models/i) as HTMLInputElement; - await user.type(search, "gpt"); - const gptRow = screen - .getAllByRole("option") - .find((el) => el.getAttribute("data-model-id") === GPT.id)!; - expect(gptRow).toBeDefined(); - await user.click(gptRow); - expect(onChange).toHaveBeenCalledWith(GPT.id); - expect(onReasoningEffortChange).toHaveBeenCalledWith("high"); - }); - - it("persists reasoning to family memory when the footer control changes", async () => { - const user = userEvent.setup(); - const onReasoningEffortChange = vi.fn(); - render( - , - ); - await user.click(screen.getByRole("button", { name: /Select model/i })); - const radios = screen.getAllByRole("radio"); - const medium = radios.find((el) => el.textContent === "Medium"); - expect(medium).toBeTruthy(); - await user.click(medium!); - expect(onReasoningEffortChange).toHaveBeenCalledWith("medium"); - expect(reasoningByFamilyStore.anthropic).toBe("medium"); - }); - it("shows the correct tooltip on the authOnly toggle and calls toggle on click", async () => { const user = userEvent.setup(); authOnlyState = true; @@ -414,8 +357,8 @@ describe("ModelPicker", () => { '[data-model-picker-auth-toggle="true"]', ) as HTMLButtonElement; expect(toggle).toBeTruthy(); - expect(toggle.getAttribute("aria-pressed")).toBe("true"); - expect(toggle.getAttribute("title")).toMatch(/click to show all/i); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + expect(toggle.getAttribute("title")).toMatch(/include unauthenticated providers/i); await user.click(toggle); expect(authOnlyState).toBe(false); }); @@ -433,43 +376,64 @@ describe("ModelPicker", () => { expect(ids).not.toContain(GPT.id); }); - it("shows inline reasoning chips in Recents view and cycles effort without selecting the row", async () => { + it("renders the Set up banner when the active rail is unauthed and onOpenSignIn is wired", async () => { const user = userEvent.setup(); - recentStore.unshift(SONNET.id); - reasoningByFamilyStore.anthropic = "low"; - const onChange = vi.fn(); - const onReasoningEffortChange = vi.fn(); + providerAuthStatusInternal = { anthropic: "unauthed", openai: "unauthed" }; + const onOpenSignIn = vi.fn(); render( , ); await user.click(screen.getByRole("button", { name: /Select model/i })); - const sonnetRow = screen - .getAllByRole("option") - .find((el) => el.getAttribute("data-model-id") === SONNET.id)!; - const chip = sonnetRow.querySelector('button[aria-label*="Reasoning effort"]') as HTMLButtonElement; - expect(chip).toBeTruthy(); - expect(chip.textContent).toMatch(/Low/i); - await user.click(chip); - // Cycle from "low" -> "medium" (tiers = ["low","medium","high"]) - expect(reasoningByFamilyStore.anthropic).toBe("medium"); - // Should not select the model - expect(onChange).not.toHaveBeenCalled(); + const banner = document.querySelector('[data-model-picker-setup-banner="true"]') as HTMLButtonElement; + expect(banner).toBeTruthy(); + expect(banner.getAttribute("data-provider-family")).toBe("anthropic"); + await user.click(banner); + expect(onOpenSignIn).toHaveBeenCalledOnce(); }); - it("does not show inline reasoning chips in a provider rail view", async () => { + it("does not render the Set up banner when the active rail is authed", async () => { const user = userEvent.setup(); + providerAuthStatusInternal = { anthropic: "ok", openai: "unauthed" }; + render( + , + ); + await user.click(screen.getByRole("button", { name: /Select model/i })); + expect(document.querySelector('[data-model-picker-setup-banner="true"]')).toBeNull(); + }); + + it("does not render the Set up banner when onOpenSignIn is not provided", async () => { + const user = userEvent.setup(); + providerAuthStatusInternal = { anthropic: "unauthed" }; + render( + , + ); + await user.click(screen.getByRole("button", { name: /Select model/i })); + expect(document.querySelector('[data-model-picker-setup-banner="true"]')).toBeNull(); + }); + + it("does not render inline reasoning chips inside model rows", async () => { + const user = userEvent.setup(); + recentStore.unshift(SONNET.id); reasoningByFamilyStore.anthropic = "low"; renderPicker(); await user.click(screen.getByRole("button", { name: /Select model/i })); - // No recents -> initial selection is the active model's provider rail (anthropic). const sonnetRow = screen .getAllByRole("option") .find((el) => el.getAttribute("data-model-id") === SONNET.id)!; diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx index 46e479995..55c22c42e 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx @@ -13,7 +13,6 @@ import { ModelPickerContent } from "./ModelPickerContent"; import type { AuthStatus } from "./ModelPickerRail"; import { createUnknownModelPlaceholder, mergeSelectorModels } from "./modelCatalog"; import { useModelRecents } from "./useModelRecents"; -import { useReasoningByFamily } from "./useReasoningByFamily"; export type ModelPickerProps = { value: string; @@ -21,9 +20,6 @@ export type ModelPickerProps = { surfaceKey: string; compact?: boolean; disabled?: boolean; - showReasoning?: boolean; - reasoningEffort?: string | null; - onReasoningEffortChange?: (effort: string | null) => void; availableModelIds?: string[]; catalogMode?: "all" | "available-only"; filter?: (model: ModelDescriptor) => boolean; @@ -37,28 +33,12 @@ export type ModelPickerProps = { triggerClassName?: string; }; -function reasoningChipLabel(effort: string | null | undefined): string | null { - if (!effort) return null; - const lower = effort.trim().toLowerCase(); - if (!lower) return null; - if (lower === "minimal") return "MIN"; - if (lower === "low") return "LOW"; - if (lower === "medium") return "MED"; - if (lower === "high") return "HI"; - if (lower === "xhigh") return "XH"; - if (lower === "max") return "MAX"; - return lower.slice(0, 3).toUpperCase(); -} - export const ModelPicker = memo(function ModelPicker({ value, onChange, surfaceKey, compact = false, disabled = false, - showReasoning, - reasoningEffort = null, - onReasoningEffortChange, availableModelIds, catalogMode, filter, @@ -73,7 +53,6 @@ export const ModelPicker = memo(function ModelPicker({ }: ModelPickerProps) { const [open, setOpen] = useState(false); const { recents } = useModelRecents(); - const { getReasoningForFamily } = useReasoningByFamily(); const modelList = useMemo(() => { if (models && models.length) return models; @@ -111,34 +90,16 @@ export const ModelPicker = memo(function ModelPicker({ const handleSelect = useCallback( (modelId: string) => { - // When selecting a model from a different family, restore that family's - // remembered reasoning effort so callers don't carry stale state across providers. - if (onReasoningEffortChange) { - const previous = selectedModel?.family; - const nextDescriptor = resolveModelDescriptor(modelId); - const nextFamily = nextDescriptor?.family; - if (nextFamily && previous && nextFamily !== previous) { - const remembered = getReasoningForFamily(nextFamily); - onReasoningEffortChange(remembered); - } - } onChange(modelId); setOpen(false); }, - [getReasoningForFamily, onChange, onReasoningEffortChange, selectedModel], + [onChange], ); const handleRequestClose = useCallback(() => { setOpen(false); }, []); - const triggerReasoning = - showReasoning && selectedModel && (selectedModel.reasoningTiers?.length ?? 0) > 0 - ? reasoningChipLabel( - (value && reasoningEffort) || getReasoningForFamily(selectedModel.family), - ) - : null; - const triggerFastSupported = typeof fastModeSupported === "boolean" ? fastModeSupported @@ -164,7 +125,6 @@ export const ModelPicker = memo(function ModelPicker({ compact={compact} disabled={disabled} open={open} - reasoningLabel={triggerReasoning} className={triggerClassName} /> @@ -189,9 +149,6 @@ export const ModelPicker = memo(function ModelPicker({ {...(providerAuthStatus ? { providerAuthStatus } : {})} onSelect={handleSelect} onRequestClose={handleRequestClose} - {...(showReasoning ? { showReasoning: true } : {})} - reasoningEffort={reasoningEffort} - {...(onReasoningEffortChange ? { onReasoningEffortChange } : {})} {...(onOpenSignIn ? { onOpenSignIn } : {})} /> ) : null} @@ -216,14 +173,13 @@ type TriggerProps = { compact: boolean; disabled: boolean; open: boolean; - reasoningLabel: string | null; className?: string; }; const ModelPickerTrigger = memo( forwardRef>( function ModelPickerTrigger( - { model, value, compact, disabled, open, reasoningLabel, className, ...rest }, + { model, value, compact, disabled, open, className, ...rest }, ref, ) { const label = model?.displayName ?? value ?? "Select model"; @@ -260,17 +216,6 @@ const ModelPickerTrigger = memo( /> ) : null} {label} - {reasoningLabel ? ( - - {reasoningLabel} - - ) : null} > = { anthropic: "Anthropic", @@ -73,9 +73,6 @@ export type ModelPickerContentProps = { providerAuthStatus?: Partial>; onSelect: (modelId: string) => void; onRequestClose: () => void; - showReasoning?: boolean; - reasoningEffort?: string | null; - onReasoningEffortChange?: (effort: string | null) => void; onOpenSignIn?: () => void; }; @@ -87,9 +84,6 @@ export const ModelPickerContent = memo(function ModelPickerContent({ providerAuthStatus, onSelect, onRequestClose, - showReasoning, - reasoningEffort = null, - onReasoningEffortChange, onOpenSignIn, }: ModelPickerContentProps) { const [query, setQuery] = useState(""); @@ -100,7 +94,6 @@ export const ModelPickerContent = memo(function ModelPickerContent({ const { recents, recordUsage } = useModelRecents(); const { authOnly, toggleAuthOnly } = useAuthOnlyFilter(); const { setDefault: setSurfaceDefault } = usePerSurfaceModelDefaults(); - const { rememberReasoning, getReasoningForFamily } = useReasoningByFamily(); const internalAuth = useProviderAuthStatus(); const recentSet = useMemo(() => new Set(recents), [recents]); @@ -130,6 +123,16 @@ export const ModelPickerContent = memo(function ModelPickerContent({ if (m.deprecated) continue; if (!merged.has(m.id)) merged.set(m.id, m); } + // Also include canonical descriptors for dynamic-only providers (Droid, + // Cursor, OpenCode) so the picker shows the full catalog when "Show all + // models" is on. Only inject when that family has no real discovered + // models in `models` — otherwise discovery output wins. + const familiesWithRealModels = new Set(); + for (const m of models) familiesWithRealModels.add(m.family); + for (const descriptor of dynamicCanonicalDescriptors()) { + if (familiesWithRealModels.has(descriptor.family)) continue; + if (!merged.has(descriptor.id)) merged.set(descriptor.id, descriptor); + } return [...merged.values()]; }, [authOnly, models]); @@ -364,62 +367,25 @@ export const ModelPickerContent = memo(function ModelPickerContent({ [expandedModels, value], ); - // Pick a "presentation model" used for the reasoning footer when no value is selected - // or the active model has no reasoning tiers. - const reasoningPresentationModel = useMemo(() => { - if (activeModel && (activeModel.reasoningTiers?.length ?? 0) > 0) { - return activeModel; - } - const firstWithReasoning = visibleModels.find((m) => (m.reasoningTiers?.length ?? 0) > 0); - if (firstWithReasoning) return firstWithReasoning; - const anyWithReasoning = expandedModels.find((m) => (m.reasoningTiers?.length ?? 0) > 0); - return anyWithReasoning ?? null; - }, [activeModel, expandedModels, visibleModels]); - - const reasoningTiers = reasoningPresentationModel?.reasoningTiers ?? []; - - const reasoningFamily = reasoningPresentationModel?.family ?? null; - const displayedReasoningEffort = useMemo(() => { - // If the picker is tied to a real active model with explicit effort, use it. - if (activeModel && reasoningEffort) return reasoningEffort; - // Otherwise, fall back to the family-remembered effort for the presentation model. - if (reasoningFamily) return getReasoningForFamily(reasoningFamily); - return reasoningEffort; - }, [activeModel, reasoningEffort, reasoningFamily, getReasoningForFamily]); - - const handleReasoningChange = useCallback( - (next: string | null) => { - if (reasoningFamily) { - rememberReasoning(reasoningFamily, next); - } - onReasoningEffortChange?.(next); - }, - [onReasoningEffortChange, reasoningFamily, rememberReasoning], - ); - - // Inline per-row reasoning chips appear only in Favorites/Recents (and search results), - // never in provider rail views (the dedicated footer covers those). - const showInlineReasoningChips = - !searchActive && (selection === "favorites" || selection === "recents"); - - const cycleReasoningForModel = useCallback( - (model: ModelDescriptor) => { - const tiers = model.reasoningTiers; - if (!tiers || tiers.length === 0) return; - const current = getReasoningForFamily(model.family); - const idx = current ? tiers.indexOf(current) : -1; - const nextIdx = idx < 0 ? 0 : (idx + 1) % tiers.length; - const next = tiers[nextIdx] ?? null; - rememberReasoning(model.family, next); - if (activeModel?.family === model.family) { - onReasoningEffortChange?.(next); - } - }, - [activeModel, getReasoningForFamily, onReasoningEffortChange, rememberReasoning], - ); - const isEmpty = visibleModels.length === 0; + // Family of the active provider rail (null when on Favorites/Recents/search) + // — used to decide whether to render the inline "Set up {Provider}" banner. + const activeProviderFamily = useMemo(() => { + if (searchActive) return null; + if (selection === "favorites" || selection === "recents") return null; + return selection.slice("provider:".length) as ProviderFamily; + }, [searchActive, selection]); + + // Show the setup banner only when the active provider rail is unauthed AND + // the caller has wired a sign-in handler. Auth status of `undefined` (no + // signal yet) is treated as "not unauthed" so the banner doesn't flash + // before status loads. + const showSetupBanner = + activeProviderFamily != null + && onOpenSignIn != null + && effectiveAuth[activeProviderFamily] === "unauthed"; + // Sticky "Currently using" detection — show when active row is not in the visible window. const activeRowVisibleRef = useRef(true); const [activeOutOfView, setActiveOutOfView] = useState(false); @@ -537,8 +503,16 @@ export const ModelPickerContent = memo(function ModelPickerContent({
) : null} + {showSetupBanner && activeProviderFamily ? ( + + ) : null} + {isEmpty ? ( - + ) : (
{groupedRows.map((group, gi) => ( @@ -573,16 +547,6 @@ export const ModelPickerContent = memo(function ModelPickerContent({ onCopyId={handleCopyId} onSetSurfaceDefault={handleSetSurfaceDefault} {...(onOpenSignIn ? { onSignIn: onOpenSignIn } : {})} - {...(showInlineReasoningChips && m.reasoningTiers?.length - ? { - inlineReasoningChip: { - visible: true, - effort: getReasoningForFamily(m.family), - tiers: m.reasoningTiers, - onCycle: () => cycleReasoningForModel(m), - }, - } - : {})} />
); @@ -592,14 +556,6 @@ export const ModelPickerContent = memo(function ModelPickerContent({ )} - - {showReasoning && onReasoningEffortChange && reasoningTiers.length > 0 ? ( - - ) : null} @@ -616,10 +572,16 @@ function cssEscape(value: string): string { function EmptyState({ selection, searchActive, + onOpenSignIn, }: { selection: RailSelection; searchActive: boolean; + onOpenSignIn?: () => void; }) { + if (!searchActive && selection !== "favorites" && selection !== "recents") { + const family = selection.slice("provider:".length) as ProviderFamily; + return ; + } let body = "No models match this view."; if (searchActive) body = "No models match your search."; else if (selection === "favorites") body = "Star a model to pin it here."; diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortControl.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortControl.tsx deleted file mode 100644 index 9af604a52..000000000 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortControl.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { memo } from "react"; -import { cn } from "../../ui/cn"; - -export type ReasoningEffortControlProps = { - effort: string | null; - onChange: (effort: string | null) => void; - tiers: readonly string[]; - disabled?: boolean; - className?: string; -}; - -function tierLabel(tier: string): string { - if (tier === "xhigh") return "Extra High"; - if (tier === "max") return "Max"; - return tier.charAt(0).toUpperCase() + tier.slice(1); -} - -export const ReasoningEffortControl = memo(function ReasoningEffortControl({ - effort, - onChange, - tiers, - disabled = false, - className, -}: ReasoningEffortControlProps) { - const hasTiers = tiers.length > 0; - - return ( -
-
- - Thinking - -
- {tiers.map((tier) => { - const isActive = effort === tier; - return ( - - ); - })} -
-
-
- ); -}); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.test.tsx new file mode 100644 index 000000000..80f339ee6 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.test.tsx @@ -0,0 +1,192 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +vi.mock("@lobehub/icons", () => { + const brand = () => { + const Component = () => null; + Object.assign(Component, { + Avatar: () => null, + Color: () => null, + Combine: () => null, + Text: () => null, + colorPrimary: "#888", + title: "stub", + }); + return Component; + }; + return { + Anthropic: brand(), + Claude: brand(), + Codex: brand(), + Cursor: brand(), + Gemini: brand(), + Google: brand(), + Grok: brand(), + Groq: brand(), + Kimi: brand(), + LmStudio: brand(), + Ollama: brand(), + OpenAI: brand(), + OpenCode: brand(), + OpenRouter: brand(), + XAI: brand(), + }; +}); + +const reasoningByFamilyStore: Record = {}; + +vi.mock("./useReasoningByFamily", () => ({ + useReasoningByFamily: () => ({ + byFamily: { ...reasoningByFamilyStore }, + rememberReasoning: (family: string, effort: string | null) => { + if (effort == null || effort.length === 0) { + delete reasoningByFamilyStore[family]; + } else { + reasoningByFamilyStore[family] = effort; + } + }, + getReasoningForFamily: (family: string) => reasoningByFamilyStore[family] ?? null, + }), +})); + +import { ReasoningEffortPicker } from "./ReasoningEffortPicker"; + +const ANTHROPIC_MODEL_ID = "anthropic/claude-sonnet-4-6"; +const OPENCODE_MODEL_ID = "opencode/some-model-without-reasoning"; + +beforeEach(() => { + for (const key of Object.keys(reasoningByFamilyStore)) delete reasoningByFamilyStore[key]; +}); + +afterEach(() => { + cleanup(); +}); + +describe("ReasoningEffortPicker", () => { + it("renders nothing when the model has no reasoning tiers", () => { + const { container } = render( + , + ); + expect(container.querySelector('[data-reasoning-effort-picker-trigger="true"]')).toBeNull(); + }); + + it("renders nothing when modelId is empty", () => { + const { container } = render( + , + ); + expect(container.querySelector('[data-reasoning-effort-picker-trigger="true"]')).toBeNull(); + }); + + it("shows the current effort as a chip on the trigger", () => { + render( + , + ); + const trigger = screen.getByRole("button", { name: /Reasoning effort/i }); + expect(trigger.textContent).toContain("HI"); + }); + + it("falls back to the family-remembered effort when none is provided", () => { + reasoningByFamilyStore.anthropic = "medium"; + render( + , + ); + const trigger = screen.getByRole("button", { name: /Reasoning effort/i }); + expect(trigger.textContent).toContain("MED"); + }); + + it("opens the popover and lists the model's reasoning tiers on click", async () => { + const user = userEvent.setup(); + render( + , + ); + const trigger = screen.getByRole("button", { name: /Reasoning effort/i }); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + + await user.click(trigger); + + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + const group = screen.getByRole("radiogroup", { name: /Reasoning effort/i }); + expect(group).toBeTruthy(); + const radios = screen.getAllByRole("radio"); + expect(radios.length).toBeGreaterThan(0); + }); + + it("calls onChange and persists the tier when a tier is selected", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole("button", { name: /Reasoning effort/i })); + + const radios = screen.getAllByRole("radio"); + const high = radios.find((el) => el.textContent?.includes("High")); + expect(high).toBeTruthy(); + await user.click(high!); + + expect(onChange).toHaveBeenCalledWith("high"); + expect(reasoningByFamilyStore.anthropic).toBe("high"); + }); + + it("clears the effort when the active tier is clicked again", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + reasoningByFamilyStore.anthropic = "medium"; + render( + , + ); + await user.click(screen.getByRole("button", { name: /Reasoning effort/i })); + const radios = screen.getAllByRole("radio"); + const medium = radios.find((el) => el.getAttribute("aria-checked") === "true"); + expect(medium).toBeTruthy(); + await user.click(medium!); + expect(onChange).toHaveBeenCalledWith(null); + expect(reasoningByFamilyStore.anthropic).toBeUndefined(); + }); + + it("does not open the popover when disabled", async () => { + const user = userEvent.setup(); + render( + , + ); + const trigger = screen.getByRole("button", { name: /Reasoning effort/i }); + await user.click(trigger); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + }); +}); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx new file mode 100644 index 000000000..79d986a0c --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx @@ -0,0 +1,218 @@ +import { forwardRef, memo, useCallback, useMemo, useState } from "react"; +import * as Popover from "@radix-ui/react-popover"; +import { CaretDown } from "@phosphor-icons/react"; +import { resolveModelDescriptor, type ModelDescriptor } from "../../../../shared/modelRegistry"; +import { cn } from "../../ui/cn"; +import { useReasoningByFamily } from "./useReasoningByFamily"; + +export type ReasoningEffortPickerProps = { + modelId: string; + reasoningEffort: string | null; + onChange: (effort: string | null) => void; + compact?: boolean; + disabled?: boolean; + className?: string; + triggerClassName?: string; +}; + +export function reasoningChipLabel(effort: string | null | undefined): string | null { + if (!effort) return null; + const lower = effort.trim().toLowerCase(); + if (!lower) return null; + if (lower === "minimal") return "MIN"; + if (lower === "low") return "LOW"; + if (lower === "medium") return "MED"; + if (lower === "high") return "HI"; + if (lower === "xhigh") return "XH"; + if (lower === "max") return "MAX"; + return lower.slice(0, 3).toUpperCase(); +} + +function tierLabel(tier: string): string { + if (tier === "xhigh") return "Extra High"; + if (tier === "max") return "Max"; + return tier.charAt(0).toUpperCase() + tier.slice(1); +} + +export const ReasoningEffortPicker = memo(function ReasoningEffortPicker({ + modelId, + reasoningEffort, + onChange, + compact = false, + disabled = false, + className, + triggerClassName, +}: ReasoningEffortPickerProps) { + const [open, setOpen] = useState(false); + const { rememberReasoning, getReasoningForFamily } = useReasoningByFamily(); + + const descriptor = useMemo( + () => (modelId ? resolveModelDescriptor(modelId) : undefined), + [modelId], + ); + + const tiers = descriptor?.reasoningTiers ?? []; + const family = descriptor?.family; + + const displayedEffort = useMemo(() => { + if (reasoningEffort) return reasoningEffort; + if (family) return getReasoningForFamily(family); + return null; + }, [reasoningEffort, family, getReasoningForFamily]); + + const handleSelect = useCallback( + (tier: string) => { + const next = displayedEffort === tier ? null : tier; + if (family) rememberReasoning(family, next); + onChange(next); + setOpen(false); + }, + [displayedEffort, family, onChange, rememberReasoning], + ); + + if (tiers.length === 0) return null; + + const label = reasoningChipLabel(displayedEffort) ?? "AUTO"; + + return ( + { + if (disabled) { + setOpen(false); + return; + } + setOpen(next); + }} + > + + + + + { + event.preventDefault(); + }} + > +
+
+ Thinking +
+ {tiers.map((tier) => { + const isActive = displayedEffort === tier; + return ( + + ); + })} +
+
+
+
+ ); +}); + +type TriggerProps = { + label: string; + compact: boolean; + disabled: boolean; + open: boolean; + className?: string; +}; + +const ReasoningEffortTrigger = memo( + forwardRef>( + function ReasoningEffortTrigger( + { label, compact, disabled, open, className, ...rest }, + ref, + ) { + return ( + + ); + }, + ), +); diff --git a/apps/desktop/src/renderer/components/shared/ReviewLaunchModelControls.tsx b/apps/desktop/src/renderer/components/shared/ReviewLaunchModelControls.tsx index d464ee0a2..724db1025 100644 --- a/apps/desktop/src/renderer/components/shared/ReviewLaunchModelControls.tsx +++ b/apps/desktop/src/renderer/components/shared/ReviewLaunchModelControls.tsx @@ -2,6 +2,8 @@ import React from "react"; import type { AiSettingsStatus } from "../../../shared/types"; import { deriveConfiguredModelIds } from "../../lib/modelOptions"; import { ModelPicker } from "./ModelPicker/ModelPicker"; +import { ReasoningEffortPicker } from "./ModelPicker/ReasoningEffortPicker"; +import { cn } from "../ui/cn"; type ReviewLaunchModelControlsProps = { modelId: string; @@ -50,16 +52,20 @@ export function ReviewLaunchModelControls({ }, []); return ( - onReasoningEffortChange(next ?? "")} - {...(className ? { className } : {})} - /> +
+ + onReasoningEffortChange(next ?? "")} + disabled={disabled} + /> +
); } diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index 975c0b371..b4d36e81c 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -43,6 +43,7 @@ import { AgentChatPane, type AgentChatSessionCreatedOptions } from "../chat/Agen import { ChatCommandMenu, handleCommandMenuKeyDown, type ChatCommandMenuHandle, type ChatCommandMenuItem } from "../chat/ChatCommandMenu"; import { ChatComposerShell } from "../chat/ChatComposerShell"; import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; +import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; import { getPermissionOptions, safetyColors, type PermissionOption } from "../shared/permissionOptions"; import { WorkStartSurface } from "./WorkStartSurface"; import { WorkCliSessionHeader } from "./WorkCliSessionHeader"; @@ -673,23 +674,29 @@ function WorkCliContinuationComposer({ {providerLabel} {modelProvider ? ( - ( - modelProvider === "claude" - ? model.family === "anthropic" && model.isCliWrapped - : model.family === "openai" && model.isCliWrapped - )} - compact - showReasoning - reasoningEffort={selectedReasoningEffort} - onReasoningEffortChange={setSelectedReasoningEffort} - /> +
+ ( + modelProvider === "claude" + ? model.family === "anthropic" && model.isCliWrapped + : model.family === "openai" && model.isCliWrapped + )} + compact + /> + +
) : null} Date: Mon, 18 May 2026 11:28:28 -0400 Subject: [PATCH 08/14] ModelPicker: canonical lists for dynamic providers + auth fixes + setup CTA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for the empty-rail / greyed-out feedback: 1. Canonical model lists for dynamic-only providers (Cursor, Droid, OpenCode, LM Studio). MODEL_REGISTRY only had static entries (4 Anthropic + 6 OpenAI + 1 Ollama); the dynamic providers contributed nothing without runtime discovery, so their rail panes were empty. Now the picker surfaces the saved canonical model lists for those providers always — sourced from DROID_DEFAULT_MODEL_IDS in droidModelsDiscovery.ts and analogous defaults wired into modelRegistry.ts. Rows mark isAvailable=false when the provider isn't connected so they dim with a sign-in affordance. 2. Claude greyed-out auth status bug. useProviderAuthStatus was treating anthropic as "unauthed" whenever availableProviders.claude.runtimeAvailable wasn't a literal true — but the object also exposes auth.ready and binary.present, which can be true while runtimeAvailable is still transitional. Hook now accepts either as the auth signal so Claude models stay clickable when the user is actively chatting with them. 3. New providerEmptyState component with an inline "Set up X" CTA that opens Settings → Providers for any provider the user hasn't connected. Replaces the previous "no models match this view" empty state. Includes 28 new tests across useProviderAuthStatus, modelCatalog, and providerEmptyState. Existing 17 ModelPicker tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/chat/droidModelsDiscovery.ts | 24 +-- .../shared/ModelPicker/modelCatalog.test.ts | 45 ++++ .../shared/ModelPicker/modelCatalog.ts | 64 ++++++ .../ModelPicker/providerEmptyState.test.tsx | 105 ++++++++++ .../shared/ModelPicker/providerEmptyState.tsx | 196 ++++++++++++++++++ .../ModelPicker/useProviderAuthStatus.test.ts | 107 ++++++++++ .../ModelPicker/useProviderAuthStatus.ts | 27 ++- apps/desktop/src/shared/modelRegistry.ts | 67 ++++++ 8 files changed, 606 insertions(+), 29 deletions(-) create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.test.tsx create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.tsx create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.test.ts diff --git a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts index 20551dd96..d2dde09bf 100644 --- a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts +++ b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts @@ -4,34 +4,14 @@ import { homedir } from "node:os"; import { createSession } from "@factory/droid-sdk"; import { createDynamicDroidCliModelDescriptor, + DROID_CANONICAL_MODEL_IDS, sortDroidCliDescriptorsForPicker, type ModelDescriptor, } from "../../../shared/modelRegistry"; import { spawnAsync } from "../shared/utils"; /** Default catalog when `droid` does not expose a machine-readable model list. */ -export const DROID_DEFAULT_MODEL_IDS: string[] = [ - "claude-opus-4-6", - "claude-opus-4-6-fast", - "claude-opus-4-5-20251101", - "claude-sonnet-4-5-20250929", - "claude-sonnet-4-6", - "claude-haiku-4-5-20251001", - "gpt-5.1", - "gpt-5.2", - "gpt-5.3-codex", - "gpt-5.4", - "gpt-5.4-fast", - "gpt-5.4-mini", - "gemini-3-pro-preview", - "gemini-3.1-pro-preview", - "gemini-3-flash-preview", - "glm-4.7", - "glm-5", - "glm-5.1", - "kimi-k2.5", - "minimax-m2.5", -]; +export const DROID_DEFAULT_MODEL_IDS: string[] = [...DROID_CANONICAL_MODEL_IDS]; export type DroidExecHelpModelRow = { id: string; diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts index 5a87bdc5e..411ca8e5d 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts @@ -30,4 +30,49 @@ describe("mergeSelectorModels", () => { expect(model).toBeDefined(); expect(model?.family).toBe("anthropic"); }); + + it("surfaces canonical Droid descriptors when no Droid models are discovered (catalog 'all')", () => { + const merged = mergeSelectorModels(undefined, undefined, undefined, "all"); + const droidModels = merged.filter((m) => m.family === "factory"); + // Canonical list has at least Claude/OpenAI/Gemini/Droid Core entries. + expect(droidModels.length).toBeGreaterThanOrEqual(10); + // Specific representatives from DROID_CANONICAL_MODEL_IDS. + expect(droidModels.some((m) => m.id === "droid/claude-sonnet-4-6")).toBe(true); + expect(droidModels.some((m) => m.id === "droid/gpt-5.4")).toBe(true); + expect(droidModels.some((m) => m.id === "droid/glm-5")).toBe(true); + }); + + it("surfaces canonical Cursor descriptors when no Cursor models are discovered (catalog 'all')", () => { + const merged = mergeSelectorModels(undefined, undefined, undefined, "all"); + const cursorModels = merged.filter((m) => m.family === "cursor"); + expect(cursorModels.some((m) => m.id === "cursor/auto")).toBe(true); + expect(cursorModels.some((m) => m.id === "cursor/composer-2")).toBe(true); + }); + + it("surfaces canonical OpenCode descriptors when no OpenCode models are discovered (catalog 'all')", () => { + const merged = mergeSelectorModels(undefined, undefined, undefined, "all"); + const opencodeModels = merged.filter((m) => m.family === "opencode"); + // At least one canonical entry per upstream provider should appear. + expect(opencodeModels.length).toBeGreaterThanOrEqual(8); + expect(opencodeModels.some((m) => m.openCodeProviderId === "anthropic")).toBe(true); + expect(opencodeModels.some((m) => m.openCodeProviderId === "google")).toBe(true); + }); + + it("skips canonical injection for a family when real discovered models exist", () => { + // Discovery output for Droid — should suppress canonical Droid catalog. + const merged = mergeSelectorModels(["droid/some-custom-model"], undefined, undefined, "all"); + const droidModels = merged.filter((m) => m.family === "factory"); + // Only the discovered model survives — canonical list is suppressed. + expect(droidModels.length).toBe(1); + expect(droidModels[0]!.id).toBe("droid/some-custom-model"); + }); + + it("preserves canonical entries for other dynamic providers when one is discovered", () => { + // Only Droid is discovered — Cursor/OpenCode canonical lists still surface. + const merged = mergeSelectorModels(["droid/some-model"], undefined, undefined, "all"); + const cursorModels = merged.filter((m) => m.family === "cursor"); + const opencodeModels = merged.filter((m) => m.family === "opencode"); + expect(cursorModels.length).toBeGreaterThanOrEqual(2); + expect(opencodeModels.length).toBeGreaterThanOrEqual(8); + }); }); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts index 77ffb3e47..0f7e70015 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts @@ -1,8 +1,12 @@ import { + createDynamicCursorCliModelDescriptor, createDynamicDroidCliModelDescriptor, createDynamicOpenCodeModelDescriptor, + CURSOR_CANONICAL_MODEL_IDS, + DROID_CANONICAL_MODEL_IDS, LOCAL_PROVIDER_LABELS, MODEL_REGISTRY, + OPENCODE_CANONICAL_PROVIDER_MODELS, getLocalModelIdTail, parseDynamicDroidModelRef, parseDynamicOpenCodeModelRef, @@ -12,6 +16,46 @@ import { } from "../../../../shared/modelRegistry"; import { PROVIDER_BADGE_COLORS } from "../providerModelSelectorGrouping"; +/** + * Canonical descriptors for dynamic-only providers (Droid, Cursor, OpenCode). + * These are surfaced in the picker even when the provider isn't authed so the + * user can see the catalog and follow a Sign in CTA. When the provider IS + * authed, real discovery results land in `availableModelIds` and override + * these (`mergeSelectorModels` de-dups by id). + * + * `MODEL_REGISTRY` already supplies canonical static descriptors for Anthropic + * (Claude), OpenAI (Codex), and Ollama, so they don't appear here. + */ +function buildDynamicCanonicalDescriptors(): ModelDescriptor[] { + const descriptors: ModelDescriptor[] = []; + for (const id of DROID_CANONICAL_MODEL_IDS) { + descriptors.push(createDynamicDroidCliModelDescriptor(id)); + } + for (const id of CURSOR_CANONICAL_MODEL_IDS) { + descriptors.push(createDynamicCursorCliModelDescriptor(id)); + } + for (const pair of OPENCODE_CANONICAL_PROVIDER_MODELS) { + descriptors.push( + createDynamicOpenCodeModelDescriptor("", { + openCodeProviderId: pair.providerId, + openCodeModelId: pair.modelId, + }), + ); + } + return descriptors; +} + +let dynamicCanonicalCache: ModelDescriptor[] | null = null; +/** + * Cached list of canonical descriptors for dynamic-only providers. Exposed so + * the picker's "expanded" view (when the Show all models toggle is on) can + * include the same canonical entries that `mergeSelectorModels` injects. + */ +export function dynamicCanonicalDescriptors(): ModelDescriptor[] { + if (!dynamicCanonicalCache) dynamicCanonicalCache = buildDynamicCanonicalDescriptors(); + return dynamicCanonicalCache; +} + export function createUnknownModelPlaceholder(modelId: string): ModelDescriptor { const openCode = parseDynamicOpenCodeModelRef(modelId); if (openCode) { @@ -109,6 +153,26 @@ export function mergeSelectorModels( if (filter && !filter(model)) continue; merged.set(model.id, rebucketOpenCodeFamily(model)); } + // Determine which dynamic providers already have real discovered models + // (from `availableModelIds`). For those, skip canonical injection — the + // real catalog wins. For providers with no discovery output, fall back to + // the canonical list so the picker is never empty pre-signin. + const discoveredFamilies = new Set(); + for (const rawId of availableIdSet) { + const descriptor = resolveModelDescriptor(rawId); + if (descriptor) { + // Apply the same rebucket — OpenCode-routed models all collapse to the + // "opencode" rail regardless of upstream provider family. + discoveredFamilies.add(rebucketOpenCodeFamily(descriptor).family); + } + } + for (const descriptor of dynamicCanonicalDescriptors()) { + if (filter && !filter(descriptor)) continue; + if (discoveredFamilies.has(descriptor.family)) continue; + if (!merged.has(descriptor.id)) { + merged.set(descriptor.id, rebucketOpenCodeFamily(descriptor)); + } + } } for (const rawId of availableIdSet) { diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.test.tsx new file mode 100644 index 000000000..33937e4d9 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.test.tsx @@ -0,0 +1,105 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// openExternalUrl uses window.ade.app.openExternal; stub it before importing. +const openExternalCalls: string[] = []; + +beforeEach(() => { + openExternalCalls.length = 0; + (window as unknown as { + ade?: { app?: { openExternal?: (url: string) => Promise } }; + }).ade = { + app: { + openExternal: (url: string) => { + openExternalCalls.push(url); + return Promise.resolve(); + }, + }, + }; +}); + +afterEach(() => { + cleanup(); +}); + +// Import after we wire the global so the module-eval-time bindings see it. +// (openExternalUrl reads window.ade at call time, so order doesn't strictly +// matter — but this keeps test setup consistent.) +import { ProviderEmptyState, ProviderSetupBanner } from "./providerEmptyState"; + +describe("ProviderEmptyState", () => { + it("renders cursor-specific copy and signin/external CTAs", async () => { + const onOpenSignIn = vi.fn(); + render(); + expect(screen.getByText("Connect Cursor")).toBeTruthy(); + expect(screen.getByText(/Add a Cursor API key/i)).toBeTruthy(); + await userEvent.click(screen.getByRole("button", { name: /Open Settings/i })); + expect(onOpenSignIn).toHaveBeenCalledOnce(); + await userEvent.click(screen.getByRole("button", { name: /Get Cursor API key/i })); + expect(openExternalCalls).toContain("https://cursor.com/dashboard/integrations"); + }); + + it("renders Droid (factory) copy", () => { + render(); + expect(screen.getByText(/Install Droid CLI/i)).toBeTruthy(); + }); + + it("renders OpenCode copy", () => { + render(); + expect(screen.getByText(/Install OpenCode/i)).toBeTruthy(); + }); + + it("renders LM Studio copy", () => { + render(); + expect(screen.getByText(/Start LM Studio/i)).toBeTruthy(); + }); + + it("falls back to generic copy for unknown family", () => { + render(); + expect(screen.getByText(/No models discovered/i)).toBeTruthy(); + }); + + it("does not show 'no models match this view' wording (regression)", () => { + render(); + expect(screen.queryByText(/No models match this view/i)).toBeNull(); + }); +}); + +describe("ProviderSetupBanner", () => { + it("renders provider-specific label and invokes onOpenSignIn on click", async () => { + const onOpenSignIn = vi.fn(); + render(); + const banner = screen.getByRole("button", { name: /Set up Cursor/i }); + expect(banner).toBeTruthy(); + await userEvent.click(banner); + expect(onOpenSignIn).toHaveBeenCalledOnce(); + }); + + it("uses Droid label for the 'factory' family", () => { + render(); + expect(screen.getByRole("button", { name: /Set up Droid/i })).toBeTruthy(); + }); + + it("uses Claude label for the 'anthropic' family", () => { + render(); + expect(screen.getByRole("button", { name: /Set up Claude/i })).toBeTruthy(); + }); + + it("uses OpenAI Codex label for the 'openai' family", () => { + render(); + expect(screen.getByRole("button", { name: /Set up OpenAI Codex/i })).toBeTruthy(); + }); + + it("renders nothing when onOpenSignIn is not provided", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("falls back to the family slug for unmapped families", () => { + render(); + expect(screen.getByRole("button", { name: /Set up groq/i })).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.tsx new file mode 100644 index 000000000..0f61aeca3 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.tsx @@ -0,0 +1,196 @@ +import { Gear, ArrowSquareOut } from "@phosphor-icons/react"; +import type { ProviderFamily } from "../../../../shared/modelRegistry"; +import { openExternalUrl } from "../../../lib/openExternal"; +import { cn } from "../../ui/cn"; + +export type ProviderEmptyStateAction = + | { kind: "open-settings" } + | { kind: "open-external"; url: string }; + +type ProviderCopy = { + title: string; + body: string; + primary?: { label: string; action: ProviderEmptyStateAction }; + secondary?: { label: string; action: ProviderEmptyStateAction }; +}; + +// Curated, per-provider empty-state copy. Mirrors the install hints from +// settings/ProvidersSection.tsx so a user landing here from the picker gets +// the same official URLs. +const PROVIDER_COPY: Partial> = { + anthropic: { + title: "Sign in to Claude", + body: "Install the Claude CLI and sign in, or set an Anthropic API key in Settings.", + primary: { label: "Open Settings", action: { kind: "open-settings" } }, + secondary: { + label: "Claude Code docs", + action: { kind: "open-external", url: "https://docs.claude.com/en/docs/agents-and-tools/claude-code/setup" }, + }, + }, + openai: { + title: "Sign in to OpenAI Codex", + body: "Install the Codex CLI and sign in to use OpenAI GPT models.", + primary: { label: "Open Settings", action: { kind: "open-settings" } }, + secondary: { + label: "Codex CLI install", + action: { kind: "open-external", url: "https://github.com/openai/codex" }, + }, + }, + cursor: { + title: "Connect Cursor", + body: "Add a Cursor API key in Settings to discover Cursor SDK models.", + primary: { label: "Open Settings", action: { kind: "open-settings" } }, + secondary: { + label: "Get Cursor API key", + action: { kind: "open-external", url: "https://cursor.com/dashboard/integrations" }, + }, + }, + factory: { + title: "Install Droid CLI", + body: "Install Factory's `droid` CLI and ensure it's on PATH to discover Droid models.", + primary: { label: "Open Settings", action: { kind: "open-settings" } }, + secondary: { + label: "Droid quickstart", + action: { kind: "open-external", url: "https://docs.factory.ai/cli/getting-started/quickstart" }, + }, + }, + opencode: { + title: "Install OpenCode", + body: "Install the OpenCode CLI to surface 75+ models from Anthropic, Google, Mistral, DeepSeek, and more.", + primary: { label: "Open Settings", action: { kind: "open-settings" } }, + secondary: { + label: "OpenCode site", + action: { kind: "open-external", url: "https://opencode.ai/" }, + }, + }, + lmstudio: { + title: "Start LM Studio", + body: "Launch the LM Studio app and load a model — ADE will pick it up automatically.", + primary: { label: "Open Settings", action: { kind: "open-settings" } }, + secondary: { + label: "Download LM Studio", + action: { kind: "open-external", url: "https://lmstudio.ai/" }, + }, + }, + ollama: { + title: "Start Ollama", + body: "Run the Ollama daemon and pull a model — ADE auto-discovers local models.", + primary: { label: "Open Settings", action: { kind: "open-settings" } }, + secondary: { + label: "Get Ollama", + action: { kind: "open-external", url: "https://ollama.com/download" }, + }, + }, +}; + +function dispatchAction(action: ProviderEmptyStateAction, onOpenSignIn?: () => void): void { + if (action.kind === "open-settings") { + onOpenSignIn?.(); + return; + } + openExternalUrl(action.url); +} + +export type ProviderEmptyStateProps = { + family: ProviderFamily; + onOpenSignIn?: () => void; +}; + +const PROVIDER_DISPLAY_LABELS: Partial> = { + anthropic: "Claude", + openai: "OpenAI Codex", + cursor: "Cursor", + factory: "Droid", + opencode: "OpenCode", + lmstudio: "LM Studio", + ollama: "Ollama", +}; + +export type ProviderSetupBannerProps = { + family: ProviderFamily; + onOpenSignIn?: () => void; +}; + +/** + * Small inline "Set up {Provider}" banner. Rendered above the model list when + * the user is browsing a provider rail they haven't authed yet, so the + * affordance is visible alongside the dimmed canonical rows. + * + * Falls back to a generic "Set up provider" label for unmapped families. When + * no `onOpenSignIn` handler is provided the banner is hidden (no dead button). + */ +export function ProviderSetupBanner({ family, onOpenSignIn }: ProviderSetupBannerProps) { + if (!onOpenSignIn) return null; + const label = PROVIDER_DISPLAY_LABELS[family] ?? family; + return ( + + ); +} + +export function ProviderEmptyState({ family, onOpenSignIn }: ProviderEmptyStateProps) { + const copy = PROVIDER_COPY[family]; + if (!copy) { + return ( +
+ + No models discovered for this provider yet. + +
+ ); + } + return ( +
+ {copy.title} + {copy.body} +
+ {copy.primary ? ( + + ) : null} + {copy.secondary ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.test.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.test.ts new file mode 100644 index 000000000..30f28bd03 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { familiesFromStatus } from "./useProviderAuthStatus"; + +// `familiesFromStatus` is the pure mapper inside useProviderAuthStatus. +// We test it directly so the Claude availability shape (object with binary/auth, +// no `runtimeAvailable`) is correctly interpreted as "ok" when the user is +// actually authenticated. Prior regression: hook checked a nonexistent +// `runtimeAvailable` field and dimmed every Claude row even for working users. +describe("familiesFromStatus", () => { + it("marks Claude as ok when auth.ready is true (no runtimeAvailable field)", () => { + const out = familiesFromStatus({ + availableProviders: { + claude: { + binary: { present: true, source: "bundled", path: "/usr/local/bin/claude" }, + auth: { ready: true, mode: "oauth", detail: null }, + }, + codex: false, + cursor: false, + droid: false, + }, + }); + expect(out.anthropic).toBe("ok"); + }); + + it("marks Claude as unauthed when auth.ready is false", () => { + const out = familiesFromStatus({ + availableProviders: { + claude: { + binary: { present: true, source: "bundled", path: null }, + auth: { ready: false, mode: "none", detail: "no login detected" }, + }, + codex: false, + cursor: false, + droid: false, + }, + }); + expect(out.anthropic).toBe("unauthed"); + }); + + it("treats legacy boolean Claude availability as the boolean value", () => { + expect( + familiesFromStatus({ availableProviders: { claude: true, codex: false, cursor: false, droid: false } }) + .anthropic, + ).toBe("ok"); + expect( + familiesFromStatus({ availableProviders: { claude: false, codex: false, cursor: false, droid: false } }) + .anthropic, + ).toBe("unauthed"); + }); + + it("honors legacy runtimeAvailable: true when present (backward compat with stubs)", () => { + const out = familiesFromStatus({ + availableProviders: { + claude: { runtimeAvailable: true } as unknown, + codex: false, + cursor: false, + droid: false, + }, + }); + expect(out.anthropic).toBe("ok"); + }); + + it("maps codex/cursor/droid booleans correctly", () => { + const out = familiesFromStatus({ + availableProviders: { + claude: false, + codex: true, + cursor: false, + droid: true, + }, + }); + expect(out.openai).toBe("ok"); + expect(out.cursor).toBe("unauthed"); + expect(out.factory).toBe("ok"); + }); + + it("sets opencode ok when any opencode provider is connected", () => { + const out = familiesFromStatus({ + availableProviders: { claude: false, codex: false, cursor: false, droid: false }, + opencodeProviders: [ + { id: "anthropic", connected: false }, + { id: "google", connected: true }, + ], + }); + expect(out.opencode).toBe("ok"); + }); + + it("omits opencode entry when no opencode providers are connected", () => { + const out = familiesFromStatus({ + availableProviders: { claude: false, codex: false, cursor: false, droid: false }, + opencodeProviders: [ + { id: "anthropic", connected: false }, + { id: "google", connected: false }, + ], + }); + expect(out.opencode).toBeUndefined(); + }); + + it("handles empty/missing input gracefully", () => { + const out = familiesFromStatus({}); + expect(out.anthropic).toBe("unauthed"); + expect(out.openai).toBe("unauthed"); + expect(out.cursor).toBe("unauthed"); + expect(out.factory).toBe("unauthed"); + expect(out.opencode).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts index 57b8729f4..69cdad144 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts @@ -22,17 +22,30 @@ const useProviderAuthStore = create((set) => ({ setInFlight: (promise) => set({ inFlight: promise }), })); -function familiesFromStatus(status: { +// AiClaudeAvailability is an object with `binary.present` + `auth.ready` — +// historic callers also passed plain booleans, so accept both. We treat Claude +// as "ok" whenever auth.ready is true (binary may be bundled and present even +// when path discovery says otherwise), and fall back to binary.present so the +// rail dot is still informative when only the binary half is known. +function isClaudeOk(value: unknown): boolean { + if (typeof value === "boolean") return value; + if (!value || typeof value !== "object") return false; + const claude = value as { + auth?: { ready?: boolean }; + binary?: { present?: boolean }; + runtimeAvailable?: boolean; + }; + if (claude.auth?.ready === true) return true; + if (claude.runtimeAvailable === true) return true; + return false; +} + +export function familiesFromStatus(status: { availableProviders?: { claude?: unknown; codex?: unknown; cursor?: unknown; droid?: unknown }; opencodeProviders?: Array<{ id: string; connected: boolean }>; }): AuthStatusMap { const out: AuthStatusMap = {}; - const claude = status.availableProviders?.claude; - const claudeOk = - typeof claude === "boolean" - ? claude - : Boolean(claude && typeof claude === "object" && (claude as { runtimeAvailable?: boolean }).runtimeAvailable); - out.anthropic = claudeOk ? "ok" : "unauthed"; + out.anthropic = isClaudeOk(status.availableProviders?.claude) ? "ok" : "unauthed"; out.openai = status.availableProviders?.codex === true ? "ok" : "unauthed"; out.cursor = status.availableProviders?.cursor === true ? "ok" : "unauthed"; out.factory = status.availableProviders?.droid === true ? "ok" : "unauthed"; diff --git a/apps/desktop/src/shared/modelRegistry.ts b/apps/desktop/src/shared/modelRegistry.ts index 15018c0f0..657db4a09 100644 --- a/apps/desktop/src/shared/modelRegistry.ts +++ b/apps/desktop/src/shared/modelRegistry.ts @@ -871,6 +871,73 @@ function normalizeDroidEffortLabel(value: string): string { return value.trim().replace(/\b\w/g, (char) => char.toUpperCase()); } +/** + * Canonical Droid model ids surfaced when the CLI can't be probed (provider + * unauthenticated). Kept in renderer-safe shared code so the picker can show + * the full list with sign-in CTAs. + * + * Order matches the main-process default in `droidModelsDiscovery.ts` so the + * two sources don't drift. Update both when the curated list changes. + */ +export const DROID_CANONICAL_MODEL_IDS: readonly string[] = [ + "claude-opus-4-6", + "claude-opus-4-6-fast", + "claude-opus-4-5-20251101", + "claude-sonnet-4-5-20250929", + "claude-sonnet-4-6", + "claude-haiku-4-5-20251001", + "gpt-5.1", + "gpt-5.2", + "gpt-5.3-codex", + "gpt-5.4", + "gpt-5.4-fast", + "gpt-5.4-mini", + "gemini-3-pro-preview", + "gemini-3.1-pro-preview", + "gemini-3-flash-preview", + "glm-4.7", + "glm-5", + "glm-5.1", + "kimi-k2.5", + "minimax-m2.5", +]; + +/** + * Canonical Cursor SDK model ids — surfaced even when no Cursor API key is + * configured so the user can browse the catalog before signing in. + */ +export const CURSOR_CANONICAL_MODEL_IDS: readonly string[] = [ + "auto", + "composer-2", + "claude-sonnet-4.5", + "claude-opus-4.5", + "claude-haiku-4.5", + "gpt-5.4", + "gpt-5.4-mini", + "gemini-3-pro", + "grok-4", +]; + +/** + * Canonical OpenCode provider/model pairs surfaced when the OpenCode binary + * isn't yet installed/probed. The picker materializes these into dynamic + * OpenCode descriptors via `createDynamicOpenCodeModelDescriptor`. + */ +export const OPENCODE_CANONICAL_PROVIDER_MODELS: ReadonlyArray<{ providerId: string; modelId: string }> = [ + { providerId: "anthropic", modelId: "claude-sonnet-4-6" }, + { providerId: "anthropic", modelId: "claude-opus-4-7" }, + { providerId: "anthropic", modelId: "claude-haiku-4-5" }, + { providerId: "openai", modelId: "gpt-5.4" }, + { providerId: "openai", modelId: "gpt-5.4-mini" }, + { providerId: "google", modelId: "gemini-3-pro" }, + { providerId: "google", modelId: "gemini-3-flash" }, + { providerId: "deepseek", modelId: "deepseek-r1" }, + { providerId: "xai", modelId: "grok-4" }, + { providerId: "mistral", modelId: "mistral-large" }, + { providerId: "groq", modelId: "llama-3.3-70b" }, + { providerId: "together", modelId: "qwen-2.5-coder-32b" }, +]; + const KNOWN_DROID_COMPACT_DISPLAY_NAMES: Record = { "claude-opus-4-5-20251101": "Opus 4.5 (2x)", "claude-opus-4-6": "Opus 4.6 (2x)", From 27b3ab33c0ffac7b952a7b18a42c3c502f4e3aea Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 18 May 2026 12:17:14 -0400 Subject: [PATCH 09/14] Warm OpenCode inventory cache on first getStatus peek miss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: probeOpenCodeProviderInventory only ran when getStatus() was called with refreshOpenCodeInventory=true. AgentChatPane only forces that flag when sessionProvider === "opencode" — meaning the user has to ALREADY be in an OpenCode chat for the inventory to populate. Chicken-and-egg: model picker shows zero discovered OpenCode models, user can't pick one, user never enters an OpenCode chat, cache stays empty forever. Fix: on the default code path, if peekOpenCodeInventoryCache returns null AND the OpenCode binary is installed, fall through to a probe with force=false. The in-flight dedup map + 60s TTL still apply, so the OpenCode server boots at most once per project+config across the session. Warm reads are unchanged (peek wins). The existing test "does not cold-probe OpenCode inventory on default getStatus" had codified the broken behavior — replaced with one that asserts the cold path now probes once, plus a second test that verifies a populated peek still bypasses the probe. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/ai/aiIntegrationService.test.ts | 26 ++++++++++++++++--- .../main/services/ai/aiIntegrationService.ts | 18 +++++++++---- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts index c04cbfcb5..23031a9d9 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts @@ -473,14 +473,34 @@ describe("aiIntegrationService", () => { expect(secondStatus).toEqual(firstStatus); }); - it("does not cold-probe OpenCode inventory on default getStatus", async () => { + it("warms OpenCode inventory on first getStatus when the cache is empty", async () => { const { service } = makeService(); const status = await service.getStatus(); expect(status.opencodeBinaryInstalled).toBe(true); - expect(status.opencodeProviders).toEqual([]); - expect(status.availableModelIds).not.toContain("opencode/openai/gpt-5.4-mini"); + expect(status.opencodeProviders).toEqual([ + { id: "openai", name: "OpenAI", connected: true, modelCount: 1 }, + ]); + expect(status.availableModelIds).toContain("opencode/openai/gpt-5.4-mini"); + expect(mockState.peekOpenCodeInventoryCache).toHaveBeenCalledTimes(1); + expect(mockState.probeOpenCodeProviderInventory).toHaveBeenCalledTimes(1); + expect(mockState.probeOpenCodeProviderInventory).toHaveBeenCalledWith( + expect.objectContaining({ force: false }), + ); + }); + + it("uses peeked OpenCode inventory without re-probing when cache is warm", async () => { + mockState.peekOpenCodeInventoryCache.mockReturnValue({ + modelIds: ["opencode/openai/gpt-5.4-mini"], + providers: [{ id: "openai", name: "OpenAI", connected: true, modelCount: 1 }], + error: null, + }); + const { service } = makeService(); + + const status = await service.getStatus(); + + expect(status.availableModelIds).toContain("opencode/openai/gpt-5.4-mini"); expect(mockState.peekOpenCodeInventoryCache).toHaveBeenCalledTimes(1); expect(mockState.probeOpenCodeProviderInventory).not.toHaveBeenCalled(); }); diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 5ef63ec97..06d2dcfe0 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -1816,11 +1816,19 @@ export function createAiIntegrationService(args: { projectRoot, projectConfig: effectiveConfig, }); - return peeked ?? { - error: null as string | null, - modelIds: [] as string[], - providers: [] as NonNullable, - }; + if (peeked) return peeked; + // Cold path: binary is installed but we have never probed for this + // project+config. Warm the cache so the model picker surfaces every + // connected OpenCode provider without requiring the user to enter + // an OpenCode chat first. Uses force=false so the in-flight + // dedup + TTL still apply, preventing repeated server boots. + return await probeOpenCodeProviderInventory({ + projectRoot, + projectConfig: effectiveConfig, + logger, + force: false, + discoveredLocalModels, + }); }); // When OpenCode inventory has models for a local provider, remove the From d65e432e0cb198e7f2faf21b62ac5892ef54650a Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 18 May 2026 12:17:30 -0400 Subject: [PATCH 10/14] ModelPicker: drop canonical preview lists; gate Ollama/LMStudio/OpenCode on binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per user direction, the preview-canonical approach is wrong. Replaced with a clean separation: - Anthropic + OpenAI: unchanged. Hardcoded MODEL_REGISTRY entries remain because they encode first-party CLI/SDK routing metadata that the vendors don't expose via a list-models API. - Cursor + Droid: zero hardcoded preview. When the CLI is installed + authed, dynamic discovery surfaces models. When not, the pane shows ONLY a "Set up X" empty state — no preview rows. - OpenCode + Ollama + LM Studio: same pattern, but all three are gated on OpenCode binary availability. When the OpenCode binary is missing, all three panes show the SAME "Install OpenCode" empty state. When present, models flow through probeOpenCodeProviderInventory. Changes: - Deleted DROID_CANONICAL_MODEL_IDS, CURSOR_CANONICAL_MODEL_IDS, OPENCODE_CANONICAL_PROVIDER_MODELS from modelRegistry.ts and the DROID_DEFAULT_MODEL_IDS re-export in droidModelsDiscovery.ts. - Removed buildDynamicCanonicalDescriptors + canonical-injection path from modelCatalog.ts and ModelPickerContent.expandedModels. - useProviderAuthStatus now tracks opencodeBinaryInstalled via a new opencodeBinaryInstalledFromStatus mapper; hook returns { status, opencodeBinaryInstalled, loaded }. - ProviderEmptyState gains a discriminated "opencode-required" mode with family in opencode/ollama/lmstudio. Identical title + body + CTAs across all three families. - ModelPickerContent.filterAvailable hides opencode/ollama/lmstudio models entirely when binary missing so the pane falls through to the opencode-required empty state. ProviderSetupBanner suppressed for those families in that state (single unified empty message). Tests updated: - modelCatalog.test.ts: removed canonical-surfacing assertions, added negative assertions for no-canonical-injection. Rebucket-to-opencode and discovered-droid-model tests retained and tightened. - providerEmptyState.test.tsx: new opencode-required describe block (16 tests total). - useProviderAuthStatus.test.ts: new describe for opencodeBinaryInstalledFromStatus boolean coercion. - ModelPicker.test.tsx: new describe for OpenCode binary gating (asserts identical empty state copy across all three rails; ProviderSetupBanner suppressed). Total: 4 ModelPicker test files, 54 tests, all pass. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/chat/droidModelsDiscovery.ts | 8 +-- .../shared/ModelPicker/ModelPicker.test.tsx | 63 +++++++++++++++++ .../shared/ModelPicker/ModelPickerContent.tsx | 52 ++++++++++---- .../shared/ModelPicker/modelCatalog.test.ts | 34 ++-------- .../shared/ModelPicker/modelCatalog.ts | 64 ------------------ .../ModelPicker/providerEmptyState.test.tsx | 38 +++++++++++ .../shared/ModelPicker/providerEmptyState.tsx | 43 ++++++++++-- .../ModelPicker/useProviderAuthStatus.test.ts | 21 +++++- .../ModelPicker/useProviderAuthStatus.ts | 31 +++++++-- apps/desktop/src/shared/modelRegistry.ts | 67 ------------------- 10 files changed, 229 insertions(+), 192 deletions(-) diff --git a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts index d2dde09bf..8895ef41a 100644 --- a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts +++ b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts @@ -4,15 +4,11 @@ import { homedir } from "node:os"; import { createSession } from "@factory/droid-sdk"; import { createDynamicDroidCliModelDescriptor, - DROID_CANONICAL_MODEL_IDS, sortDroidCliDescriptorsForPicker, type ModelDescriptor, } from "../../../shared/modelRegistry"; import { spawnAsync } from "../shared/utils"; -/** Default catalog when `droid` does not expose a machine-readable model list. */ -export const DROID_DEFAULT_MODEL_IDS: string[] = [...DROID_CANONICAL_MODEL_IDS]; - export type DroidExecHelpModelRow = { id: string; displayName: string; @@ -265,9 +261,7 @@ export async function discoverDroidCliModelDescriptors( const fromSdk = options?.mode === "cached-or-fallback" ? getCachedDroidModels() ?? [] : await listDroidModelsFromSdk(droidPath).catch(() => []); - const baseRows: DroidExecHelpModelRow[] = fromSdk.length - ? fromSdk - : DROID_DEFAULT_MODEL_IDS.map((id) => ({ id, displayName: id })); + const baseRows: DroidExecHelpModelRow[] = fromSdk; // Merge custom models from ~/.factory/config.json so vibeproxy-injected // models appear even when the CLI help output doesn't list them. diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx index 3f23dca58..bc37f2989 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx @@ -42,6 +42,7 @@ const recentStore: string[] = []; let authOnlyState = false; const reasoningByFamilyStore: Record = {}; let providerAuthStatusInternal: Record = {}; +let opencodeBinaryInstalledInternal = true; vi.mock("./useModelFavorites", () => ({ useModelFavorites: () => ({ @@ -99,6 +100,7 @@ vi.mock("./useReasoningByFamily", () => ({ vi.mock("./useProviderAuthStatus", () => ({ useProviderAuthStatus: () => ({ status: { ...providerAuthStatusInternal }, + opencodeBinaryInstalled: opencodeBinaryInstalledInternal, loaded: true, }), })); @@ -186,6 +188,7 @@ beforeEach(() => { authOnlyState = false; for (const key of Object.keys(reasoningByFamilyStore)) delete reasoningByFamilyStore[key]; providerAuthStatusInternal = {}; + opencodeBinaryInstalledInternal = true; }); afterEach(() => { @@ -440,4 +443,64 @@ describe("ModelPicker", () => { const chip = sonnetRow.querySelector('button[aria-label*="Reasoning effort"]'); expect(chip).toBeNull(); }); + + describe("OpenCode binary gating", () => { + it("shows the same Install OpenCode copy for opencode, ollama, and lmstudio panes when the binary is missing", async () => { + const user = userEvent.setup(); + opencodeBinaryInstalledInternal = false; + renderPicker(); + await user.click(screen.getByRole("button", { name: /Select model/i })); + + const families: Array<"opencode" | "ollama" | "lmstudio"> = ["opencode", "ollama", "lmstudio"]; + const seenTitles = new Set(); + const seenBodies = new Set(); + for (const family of families) { + const railButton = document.querySelector( + `[data-rail-selection="provider:${family}"]`, + ) as HTMLButtonElement | null; + expect(railButton).toBeTruthy(); + await user.click(railButton!); + + const emptyState = document.querySelector( + `[data-empty-state-mode="opencode-required"][data-provider-family="${family}"]`, + ); + expect(emptyState).toBeTruthy(); + + const title = emptyState!.querySelector("span")!.textContent; + seenTitles.add((title ?? "").trim()); + const bodyText = emptyState!.textContent ?? ""; + // Capture the body line (after the title) so we can confirm it follows + // a consistent pattern across all three panes. + seenBodies.add(bodyText.includes("Install OpenCode to use") ? "shared-body" : "other"); + } + + // All three panes converge on the same Install OpenCode title. + expect(seenTitles.size).toBe(1); + expect([...seenTitles][0]).toBe("Install OpenCode"); + // And the body uses the same "Install OpenCode to use … models." pattern. + expect(seenBodies).toEqual(new Set(["shared-body"])); + }); + + it("does not render the regular Set up banner for opencode/ollama/lmstudio rails when OpenCode is missing", async () => { + const user = userEvent.setup(); + opencodeBinaryInstalledInternal = false; + providerAuthStatusInternal = { opencode: "unauthed" }; + const onOpenSignIn = vi.fn(); + render( + , + ); + await user.click(screen.getByRole("button", { name: /Select model/i })); + const opencodeRail = document.querySelector( + '[data-rail-selection="provider:opencode"]', + ) as HTMLButtonElement; + await user.click(opencodeRail); + expect(document.querySelector('[data-model-picker-setup-banner="true"]')).toBeNull(); + }); + }); }); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx index f7803ceec..622093e30 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx @@ -20,7 +20,6 @@ import { useProviderAuthStatus } from "./useProviderAuthStatus"; import { scoreModelPickerSearch } from "./modelPickerSearch"; import { sortModelItems } from "./modelOrdering"; import { ProviderEmptyState, ProviderSetupBanner } from "./providerEmptyState"; -import { dynamicCanonicalDescriptors } from "./modelCatalog"; const PROVIDER_LABELS: Partial> = { anthropic: "Anthropic", @@ -106,6 +105,13 @@ export const ModelPickerContent = memo(function ModelPickerContent({ return internalAuth.status; }, [providerAuthStatus, internalAuth.status]); + const opencodeBinaryInstalled = internalAuth.opencodeBinaryInstalled; + const isOpencodeRequiredFamily = useCallback( + (family: ProviderFamily): family is "opencode" | "ollama" | "lmstudio" => + family === "opencode" || family === "ollama" || family === "lmstudio", + [], + ); + const familyIsReady = useCallback( (family: ProviderFamily): boolean => { const status = effectiveAuth[family]; @@ -123,16 +129,6 @@ export const ModelPickerContent = memo(function ModelPickerContent({ if (m.deprecated) continue; if (!merged.has(m.id)) merged.set(m.id, m); } - // Also include canonical descriptors for dynamic-only providers (Droid, - // Cursor, OpenCode) so the picker shows the full catalog when "Show all - // models" is on. Only inject when that family has no real discovered - // models in `models` — otherwise discovery output wins. - const familiesWithRealModels = new Set(); - for (const m of models) familiesWithRealModels.add(m.family); - for (const descriptor of dynamicCanonicalDescriptors()) { - if (familiesWithRealModels.has(descriptor.family)) continue; - if (!merged.has(descriptor.id)) merged.set(descriptor.id, descriptor); - } return [...merged.values()]; }, [authOnly, models]); @@ -188,6 +184,12 @@ export const ModelPickerContent = memo(function ModelPickerContent({ // when off, all models are shown (including unauthed, which the row dims + offers sign-in). const filterAvailable = useCallback( (m: ModelDescriptor): boolean => { + // Models for opencode/ollama/lmstudio require the OpenCode CLI runtime. + // When OpenCode isn't installed, hide them across all views so the panes + // surface a unified "Install OpenCode" empty state. + if (!opencodeBinaryInstalled && isOpencodeRequiredFamily(m.family)) { + return false; + } if (!authOnly) return true; // Prefer auth-derived gate; fall back to caller-provided `isAvailable` if no auth signal exists. if (Object.keys(effectiveAuth).length > 0) { @@ -195,7 +197,7 @@ export const ModelPickerContent = memo(function ModelPickerContent({ } return isAvailable(m.id); }, - [authOnly, effectiveAuth, familyIsReady, isAvailable], + [authOnly, effectiveAuth, familyIsReady, isAvailable, isOpencodeRequiredFamily, opencodeBinaryInstalled], ); const searchActive = query.trim().length > 0; @@ -381,10 +383,19 @@ export const ModelPickerContent = memo(function ModelPickerContent({ // the caller has wired a sign-in handler. Auth status of `undefined` (no // signal yet) is treated as "not unauthed" so the banner doesn't flash // before status loads. + // + // Suppress the per-provider banner for opencode/ollama/lmstudio when the + // OpenCode binary itself is missing — those families render a unified + // "Install OpenCode" empty state instead. + const activeFamilyNeedsOpencode = + activeProviderFamily != null + && isOpencodeRequiredFamily(activeProviderFamily) + && !opencodeBinaryInstalled; const showSetupBanner = activeProviderFamily != null && onOpenSignIn != null - && effectiveAuth[activeProviderFamily] === "unauthed"; + && effectiveAuth[activeProviderFamily] === "unauthed" + && !activeFamilyNeedsOpencode; // Sticky "Currently using" detection — show when active row is not in the visible window. const activeRowVisibleRef = useRef(true); @@ -511,6 +522,7 @@ export const ModelPickerContent = memo(function ModelPickerContent({ ) : ( @@ -572,14 +584,28 @@ function cssEscape(value: string): string { function EmptyState({ selection, searchActive, + opencodeBinaryInstalled, onOpenSignIn, }: { selection: RailSelection; searchActive: boolean; + opencodeBinaryInstalled: boolean; onOpenSignIn?: () => void; }) { if (!searchActive && selection !== "favorites" && selection !== "recents") { const family = selection.slice("provider:".length) as ProviderFamily; + if ( + !opencodeBinaryInstalled + && (family === "opencode" || family === "ollama" || family === "lmstudio") + ) { + return ( + + ); + } return ; } let body = "No models match this view."; diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts index 411ca8e5d..3d74e9659 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts @@ -31,48 +31,28 @@ describe("mergeSelectorModels", () => { expect(model?.family).toBe("anthropic"); }); - it("surfaces canonical Droid descriptors when no Droid models are discovered (catalog 'all')", () => { + it("does not surface any Droid descriptors when no Droid models are discovered (no canonical previews)", () => { const merged = mergeSelectorModels(undefined, undefined, undefined, "all"); const droidModels = merged.filter((m) => m.family === "factory"); - // Canonical list has at least Claude/OpenAI/Gemini/Droid Core entries. - expect(droidModels.length).toBeGreaterThanOrEqual(10); - // Specific representatives from DROID_CANONICAL_MODEL_IDS. - expect(droidModels.some((m) => m.id === "droid/claude-sonnet-4-6")).toBe(true); - expect(droidModels.some((m) => m.id === "droid/gpt-5.4")).toBe(true); - expect(droidModels.some((m) => m.id === "droid/glm-5")).toBe(true); + expect(droidModels.length).toBe(0); }); - it("surfaces canonical Cursor descriptors when no Cursor models are discovered (catalog 'all')", () => { + it("does not surface any Cursor descriptors when no Cursor models are discovered (no canonical previews)", () => { const merged = mergeSelectorModels(undefined, undefined, undefined, "all"); const cursorModels = merged.filter((m) => m.family === "cursor"); - expect(cursorModels.some((m) => m.id === "cursor/auto")).toBe(true); - expect(cursorModels.some((m) => m.id === "cursor/composer-2")).toBe(true); + expect(cursorModels.length).toBe(0); }); - it("surfaces canonical OpenCode descriptors when no OpenCode models are discovered (catalog 'all')", () => { + it("does not surface any OpenCode descriptors when no OpenCode models are discovered (no canonical previews)", () => { const merged = mergeSelectorModels(undefined, undefined, undefined, "all"); const opencodeModels = merged.filter((m) => m.family === "opencode"); - // At least one canonical entry per upstream provider should appear. - expect(opencodeModels.length).toBeGreaterThanOrEqual(8); - expect(opencodeModels.some((m) => m.openCodeProviderId === "anthropic")).toBe(true); - expect(opencodeModels.some((m) => m.openCodeProviderId === "google")).toBe(true); + expect(opencodeModels.length).toBe(0); }); - it("skips canonical injection for a family when real discovered models exist", () => { - // Discovery output for Droid — should suppress canonical Droid catalog. + it("surfaces only the discovered Droid model — no canonical entries are injected", () => { const merged = mergeSelectorModels(["droid/some-custom-model"], undefined, undefined, "all"); const droidModels = merged.filter((m) => m.family === "factory"); - // Only the discovered model survives — canonical list is suppressed. expect(droidModels.length).toBe(1); expect(droidModels[0]!.id).toBe("droid/some-custom-model"); }); - - it("preserves canonical entries for other dynamic providers when one is discovered", () => { - // Only Droid is discovered — Cursor/OpenCode canonical lists still surface. - const merged = mergeSelectorModels(["droid/some-model"], undefined, undefined, "all"); - const cursorModels = merged.filter((m) => m.family === "cursor"); - const opencodeModels = merged.filter((m) => m.family === "opencode"); - expect(cursorModels.length).toBeGreaterThanOrEqual(2); - expect(opencodeModels.length).toBeGreaterThanOrEqual(8); - }); }); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts index 0f7e70015..77ffb3e47 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts @@ -1,12 +1,8 @@ import { - createDynamicCursorCliModelDescriptor, createDynamicDroidCliModelDescriptor, createDynamicOpenCodeModelDescriptor, - CURSOR_CANONICAL_MODEL_IDS, - DROID_CANONICAL_MODEL_IDS, LOCAL_PROVIDER_LABELS, MODEL_REGISTRY, - OPENCODE_CANONICAL_PROVIDER_MODELS, getLocalModelIdTail, parseDynamicDroidModelRef, parseDynamicOpenCodeModelRef, @@ -16,46 +12,6 @@ import { } from "../../../../shared/modelRegistry"; import { PROVIDER_BADGE_COLORS } from "../providerModelSelectorGrouping"; -/** - * Canonical descriptors for dynamic-only providers (Droid, Cursor, OpenCode). - * These are surfaced in the picker even when the provider isn't authed so the - * user can see the catalog and follow a Sign in CTA. When the provider IS - * authed, real discovery results land in `availableModelIds` and override - * these (`mergeSelectorModels` de-dups by id). - * - * `MODEL_REGISTRY` already supplies canonical static descriptors for Anthropic - * (Claude), OpenAI (Codex), and Ollama, so they don't appear here. - */ -function buildDynamicCanonicalDescriptors(): ModelDescriptor[] { - const descriptors: ModelDescriptor[] = []; - for (const id of DROID_CANONICAL_MODEL_IDS) { - descriptors.push(createDynamicDroidCliModelDescriptor(id)); - } - for (const id of CURSOR_CANONICAL_MODEL_IDS) { - descriptors.push(createDynamicCursorCliModelDescriptor(id)); - } - for (const pair of OPENCODE_CANONICAL_PROVIDER_MODELS) { - descriptors.push( - createDynamicOpenCodeModelDescriptor("", { - openCodeProviderId: pair.providerId, - openCodeModelId: pair.modelId, - }), - ); - } - return descriptors; -} - -let dynamicCanonicalCache: ModelDescriptor[] | null = null; -/** - * Cached list of canonical descriptors for dynamic-only providers. Exposed so - * the picker's "expanded" view (when the Show all models toggle is on) can - * include the same canonical entries that `mergeSelectorModels` injects. - */ -export function dynamicCanonicalDescriptors(): ModelDescriptor[] { - if (!dynamicCanonicalCache) dynamicCanonicalCache = buildDynamicCanonicalDescriptors(); - return dynamicCanonicalCache; -} - export function createUnknownModelPlaceholder(modelId: string): ModelDescriptor { const openCode = parseDynamicOpenCodeModelRef(modelId); if (openCode) { @@ -153,26 +109,6 @@ export function mergeSelectorModels( if (filter && !filter(model)) continue; merged.set(model.id, rebucketOpenCodeFamily(model)); } - // Determine which dynamic providers already have real discovered models - // (from `availableModelIds`). For those, skip canonical injection — the - // real catalog wins. For providers with no discovery output, fall back to - // the canonical list so the picker is never empty pre-signin. - const discoveredFamilies = new Set(); - for (const rawId of availableIdSet) { - const descriptor = resolveModelDescriptor(rawId); - if (descriptor) { - // Apply the same rebucket — OpenCode-routed models all collapse to the - // "opencode" rail regardless of upstream provider family. - discoveredFamilies.add(rebucketOpenCodeFamily(descriptor).family); - } - } - for (const descriptor of dynamicCanonicalDescriptors()) { - if (filter && !filter(descriptor)) continue; - if (discoveredFamilies.has(descriptor.family)) continue; - if (!merged.has(descriptor.id)) { - merged.set(descriptor.id, rebucketOpenCodeFamily(descriptor)); - } - } } for (const rawId of availableIdSet) { diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.test.tsx index 33937e4d9..b744f4091 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.test.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.test.tsx @@ -66,6 +66,44 @@ describe("ProviderEmptyState", () => { render(); expect(screen.queryByText(/No models match this view/i)).toBeNull(); }); + + describe("opencode-required mode", () => { + it("renders identical Install OpenCode title for all three families", () => { + for (const family of ["opencode", "ollama", "lmstudio"] as const) { + cleanup(); + render(); + expect(screen.getByText("Install OpenCode")).toBeTruthy(); + } + }); + + it("uses the per-provider label in the body copy", () => { + cleanup(); + render(); + expect(screen.getByText(/Install OpenCode to use Ollama models\./i)).toBeTruthy(); + cleanup(); + render(); + expect(screen.getByText(/Install OpenCode to use LM Studio models\./i)).toBeTruthy(); + cleanup(); + render(); + expect(screen.getByText(/Install OpenCode to use OpenCode models\./i)).toBeTruthy(); + }); + + it("Open Settings CTA invokes onOpenSignIn and OpenCode site link opens externally", async () => { + const onOpenSignIn = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: /Open Settings/i })); + expect(onOpenSignIn).toHaveBeenCalledOnce(); + await userEvent.click(screen.getByRole("button", { name: /OpenCode site/i })); + expect(openExternalCalls).toContain("https://opencode.ai/"); + }); + + it("tags the rendered container with data-empty-state-mode='opencode-required'", () => { + render(); + const container = document.querySelector('[data-empty-state-mode="opencode-required"]'); + expect(container).toBeTruthy(); + expect(container?.getAttribute("data-provider-family")).toBe("lmstudio"); + }); + }); }); describe("ProviderSetupBanner", () => { diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.tsx index 0f61aeca3..37b9e5262 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.tsx @@ -91,10 +91,17 @@ function dispatchAction(action: ProviderEmptyStateAction, onOpenSignIn?: () => v openExternalUrl(action.url); } -export type ProviderEmptyStateProps = { - family: ProviderFamily; - onOpenSignIn?: () => void; -}; +export type ProviderEmptyStateProps = + | { + family: ProviderFamily; + mode?: "default"; + onOpenSignIn?: () => void; + } + | { + mode: "opencode-required"; + family: "opencode" | "ollama" | "lmstudio"; + onOpenSignIn?: () => void; + }; const PROVIDER_DISPLAY_LABELS: Partial> = { anthropic: "Claude", @@ -106,6 +113,25 @@ const PROVIDER_DISPLAY_LABELS: Partial> = { ollama: "Ollama", }; +const OPENCODE_REQUIRED_PROVIDER_LABELS: Record<"opencode" | "ollama" | "lmstudio", string> = { + opencode: "OpenCode", + ollama: "Ollama", + lmstudio: "LM Studio", +}; + +function opencodeRequiredCopy(forProvider: "opencode" | "ollama" | "lmstudio"): ProviderCopy { + const label = OPENCODE_REQUIRED_PROVIDER_LABELS[forProvider]; + return { + title: "Install OpenCode", + body: `Install OpenCode to use ${label} models.`, + primary: { label: "Open Settings", action: { kind: "open-settings" } }, + secondary: { + label: "OpenCode site", + action: { kind: "open-external", url: "https://opencode.ai/" }, + }, + }; +} + export type ProviderSetupBannerProps = { family: ProviderFamily; onOpenSignIn?: () => void; @@ -144,8 +170,12 @@ export function ProviderSetupBanner({ family, onOpenSignIn }: ProviderSetupBanne ); } -export function ProviderEmptyState({ family, onOpenSignIn }: ProviderEmptyStateProps) { - const copy = PROVIDER_COPY[family]; +export function ProviderEmptyState(props: ProviderEmptyStateProps) { + const { family, onOpenSignIn } = props; + const mode = props.mode ?? "default"; + const copy = mode === "opencode-required" + ? opencodeRequiredCopy(family as "opencode" | "ollama" | "lmstudio") + : PROVIDER_COPY[family]; if (!copy) { return (
@@ -159,6 +189,7 @@ export function ProviderEmptyState({ family, onOpenSignIn }: ProviderEmptyStateP
{copy.title} diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.test.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.test.ts index 30f28bd03..37529b1d2 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.test.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { familiesFromStatus } from "./useProviderAuthStatus"; +import { familiesFromStatus, opencodeBinaryInstalledFromStatus } from "./useProviderAuthStatus"; // `familiesFromStatus` is the pure mapper inside useProviderAuthStatus. // We test it directly so the Claude availability shape (object with binary/auth, @@ -105,3 +105,22 @@ describe("familiesFromStatus", () => { expect(out.opencode).toBeUndefined(); }); }); + +describe("opencodeBinaryInstalledFromStatus", () => { + it("returns true only when opencodeBinaryInstalled is the boolean true", () => { + expect(opencodeBinaryInstalledFromStatus({ opencodeBinaryInstalled: true })).toBe(true); + }); + + it("returns false when opencodeBinaryInstalled is false", () => { + expect(opencodeBinaryInstalledFromStatus({ opencodeBinaryInstalled: false })).toBe(false); + }); + + it("returns false when opencodeBinaryInstalled is missing", () => { + expect(opencodeBinaryInstalledFromStatus({})).toBe(false); + }); + + it("returns false for non-boolean truthy values (defensive)", () => { + expect(opencodeBinaryInstalledFromStatus({ opencodeBinaryInstalled: "yes" })).toBe(false); + expect(opencodeBinaryInstalledFromStatus({ opencodeBinaryInstalled: 1 })).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts index 69cdad144..135f4bd6b 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts @@ -8,17 +8,20 @@ type AuthStatusMap = Partial>; type ProviderAuthStore = { status: AuthStatusMap; + opencodeBinaryInstalled: boolean; loaded: boolean; inFlight: Promise | null; - setStatus: (status: AuthStatusMap) => void; + setStatus: (status: AuthStatusMap, opencodeBinaryInstalled: boolean) => void; setInFlight: (promise: Promise | null) => void; }; const useProviderAuthStore = create((set) => ({ status: {}, + opencodeBinaryInstalled: false, loaded: false, inFlight: null, - setStatus: (status) => set({ status, loaded: true, inFlight: null }), + setStatus: (status, opencodeBinaryInstalled) => + set({ status, opencodeBinaryInstalled, loaded: true, inFlight: null }), setInFlight: (promise) => set({ inFlight: promise }), })); @@ -57,21 +60,30 @@ export function familiesFromStatus(status: { return out; } +export function opencodeBinaryInstalledFromStatus(status: { opencodeBinaryInstalled?: unknown }): boolean { + return status.opencodeBinaryInstalled === true; +} + async function fetchStatus(): Promise { const store = useProviderAuthStore.getState(); if (store.inFlight) return store.inFlight; const ade = (window as unknown as { ade?: { ai?: { getStatus?: (args?: unknown) => Promise } } }).ade; const getStatus = ade?.ai?.getStatus; if (typeof getStatus !== "function") { - store.setStatus({}); + store.setStatus({}, false); return; } const promise = (async () => { try { - const raw = (await getStatus()) as Parameters[0]; - useProviderAuthStore.getState().setStatus(familiesFromStatus(raw ?? {})); + const raw = (await getStatus()) as Parameters[0] & { + opencodeBinaryInstalled?: unknown; + }; + const safe = raw ?? {}; + useProviderAuthStore + .getState() + .setStatus(familiesFromStatus(safe), opencodeBinaryInstalledFromStatus(safe)); } catch { - useProviderAuthStore.getState().setStatus({}); + useProviderAuthStore.getState().setStatus({}, false); } })(); store.setInFlight(promise); @@ -80,10 +92,15 @@ async function fetchStatus(): Promise { export function useProviderAuthStatus(): { status: AuthStatusMap; + opencodeBinaryInstalled: boolean; loaded: boolean; } { const slice = useProviderAuthStore( - useShallow((state) => ({ status: state.status, loaded: state.loaded })), + useShallow((state) => ({ + status: state.status, + opencodeBinaryInstalled: state.opencodeBinaryInstalled, + loaded: state.loaded, + })), ); // Refetch on every mount — picker mounts on popover open, so the user // signing into a provider in Settings then reopening the picker gets fresh diff --git a/apps/desktop/src/shared/modelRegistry.ts b/apps/desktop/src/shared/modelRegistry.ts index 657db4a09..15018c0f0 100644 --- a/apps/desktop/src/shared/modelRegistry.ts +++ b/apps/desktop/src/shared/modelRegistry.ts @@ -871,73 +871,6 @@ function normalizeDroidEffortLabel(value: string): string { return value.trim().replace(/\b\w/g, (char) => char.toUpperCase()); } -/** - * Canonical Droid model ids surfaced when the CLI can't be probed (provider - * unauthenticated). Kept in renderer-safe shared code so the picker can show - * the full list with sign-in CTAs. - * - * Order matches the main-process default in `droidModelsDiscovery.ts` so the - * two sources don't drift. Update both when the curated list changes. - */ -export const DROID_CANONICAL_MODEL_IDS: readonly string[] = [ - "claude-opus-4-6", - "claude-opus-4-6-fast", - "claude-opus-4-5-20251101", - "claude-sonnet-4-5-20250929", - "claude-sonnet-4-6", - "claude-haiku-4-5-20251001", - "gpt-5.1", - "gpt-5.2", - "gpt-5.3-codex", - "gpt-5.4", - "gpt-5.4-fast", - "gpt-5.4-mini", - "gemini-3-pro-preview", - "gemini-3.1-pro-preview", - "gemini-3-flash-preview", - "glm-4.7", - "glm-5", - "glm-5.1", - "kimi-k2.5", - "minimax-m2.5", -]; - -/** - * Canonical Cursor SDK model ids — surfaced even when no Cursor API key is - * configured so the user can browse the catalog before signing in. - */ -export const CURSOR_CANONICAL_MODEL_IDS: readonly string[] = [ - "auto", - "composer-2", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-haiku-4.5", - "gpt-5.4", - "gpt-5.4-mini", - "gemini-3-pro", - "grok-4", -]; - -/** - * Canonical OpenCode provider/model pairs surfaced when the OpenCode binary - * isn't yet installed/probed. The picker materializes these into dynamic - * OpenCode descriptors via `createDynamicOpenCodeModelDescriptor`. - */ -export const OPENCODE_CANONICAL_PROVIDER_MODELS: ReadonlyArray<{ providerId: string; modelId: string }> = [ - { providerId: "anthropic", modelId: "claude-sonnet-4-6" }, - { providerId: "anthropic", modelId: "claude-opus-4-7" }, - { providerId: "anthropic", modelId: "claude-haiku-4-5" }, - { providerId: "openai", modelId: "gpt-5.4" }, - { providerId: "openai", modelId: "gpt-5.4-mini" }, - { providerId: "google", modelId: "gemini-3-pro" }, - { providerId: "google", modelId: "gemini-3-flash" }, - { providerId: "deepseek", modelId: "deepseek-r1" }, - { providerId: "xai", modelId: "grok-4" }, - { providerId: "mistral", modelId: "mistral-large" }, - { providerId: "groq", modelId: "llama-3.3-70b" }, - { providerId: "together", modelId: "qwen-2.5-coder-32b" }, -]; - const KNOWN_DROID_COMPACT_DISPLAY_NAMES: Record = { "claude-opus-4-5-20251101": "Opus 4.5 (2x)", "claude-opus-4-6": "Opus 4.6 (2x)", From adec47275b4949dfc8589d3a97b4814f1b502759 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 18 May 2026 12:39:22 -0400 Subject: [PATCH 11/14] ModelPicker: don't flash 'Install OpenCode' before auth status loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useProviderAuthStore initializes opencodeBinaryInstalled to false. On first picker open, fetchStatus() takes ~2s (OpenCode server boot + provider.list). During that window the picker rendered the 'Install OpenCode' empty state on the opencode/ollama/lmstudio panes — which is wrong for users who DO have OpenCode installed. Diagnostic confirmed the daemon returns opencodeBinaryInstalled=true with 72 availableModelIds in ~2s; the renderer just hadn't received that response yet. The bug was rendering an authoritative 'not installed' claim during the loading window. Fix: treat opencode as installed until internalAuth.loaded === true. Empty state only fires after a confirmed loaded+missing signal. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../shared/ModelPicker/ModelPickerContent.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx index 622093e30..b13392a42 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx @@ -105,7 +105,15 @@ export const ModelPickerContent = memo(function ModelPickerContent({ return internalAuth.status; }, [providerAuthStatus, internalAuth.status]); - const opencodeBinaryInstalled = internalAuth.opencodeBinaryInstalled; + // Treat OpenCode as installed until the auth status loads — the initial + // store value is `false` and the fetch can take ~2s on cold-start while + // the OpenCode server boots and provider.list responds. Showing the + // "Install OpenCode" empty state during that window is wrong for users + // who DO have it installed; we only want to flip to the empty state + // after a real "loaded && not installed" signal. + const opencodeBinaryInstalled = internalAuth.loaded + ? internalAuth.opencodeBinaryInstalled + : true; const isOpencodeRequiredFamily = useCallback( (family: ProviderFamily): family is "opencode" | "ollama" | "lmstudio" => family === "opencode" || family === "ollama" || family === "lmstudio", From 0940580ce62ee56005d9757914027a5455a527f5 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 18 May 2026 13:16:08 -0400 Subject: [PATCH 12/14] ModelPicker: cheap OpenCode-installed probe + cleaner OpenCode rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for the OpenCode-detection UX: 1. Real binary detection, no defaults. Previous fix defaulted opencodeBinaryInstalled to true while the slow getStatus() was in-flight — fast for users who have it, wrong claim for users who don't. Added a cheap dedicated IPC `ai.isOpenCodeInstalled` that only runs the synchronous binary lookup (resolveOpenCodeBinary) without the 2-second server probe. Hook now runs both calls in parallel on mount; binary state is known in ~ms while the full provider/model catalog continues to populate in the background. New `binaryProbed` state field gates the "Install OpenCode" empty state on a CONFIRMED answer, not a guess. 2. Local-provider models stay in their own rail. rebucketOpenCodeFamily no longer moves family=lmstudio or family=ollama into the opencode bucket. LM Studio models discovered via OpenCode now appear in the LM Studio rail (where users expect them), Ollama in the Ollama rail. The OpenCode rail is reserved for cloud-routed models (anthropic, openai, google, etc. via the OpenCode router). 3. Drop "via OpenCode" suffix from sub-provider labels. Rows shown inside the OpenCode rail are already implicitly "via OpenCode" — the underlying provider name alone is enough. "openai via OpenCode" → "Openai". (Title-casing matches existing rail-entry capitalization.) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/services/ipc/registerIpc.ts | 9 +++++ apps/desktop/src/preload/global.d.ts | 1 + apps/desktop/src/preload/preload.ts | 2 + .../shared/ModelPicker/ModelListRow.tsx | 4 +- .../shared/ModelPicker/ModelPicker.test.tsx | 1 + .../shared/ModelPicker/ModelPickerContent.tsx | 40 ++++++++++++------- .../shared/ModelPicker/modelCatalog.ts | 16 +++++--- .../ModelPicker/useProviderAuthStatus.ts | 36 +++++++++++++++-- apps/desktop/src/shared/ipc.ts | 1 + 9 files changed, 85 insertions(+), 25 deletions(-) diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index e5bd5b37e..a16f3a0fe 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -4170,6 +4170,15 @@ export function registerIpc({ return getOpenCodeRuntimeSnapshot(); }); + // Cheap binary-only check (no probe, no server boot). Used by the renderer + // for instant first-paint of OpenCode-gated UI without waiting on the full + // ~2s getStatus() roundtrip. + ipcMain.handle(IPC.aiIsOpenCodeInstalled, async (): Promise<{ installed: boolean; source: "user-installed" | "bundled" | "missing" }> => { + const { resolveOpenCodeBinary } = await import("../opencode/openCodeBinaryManager"); + const info = resolveOpenCodeBinary(); + return { installed: Boolean(info.path), source: info.source }; + }); + ipcMain.handle(IPC.aiStoreApiKey, async (_event, arg: { provider: string; key: string }): Promise => { const ctx = getCtx(); const { storeApiKey } = await import("../ai/apiKeyStore"); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 5b6bc3873..3ddbb965d 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -900,6 +900,7 @@ declare global { refreshOpenCodeInventory?: boolean; }) => Promise; getOpenCodeRuntimeDiagnostics: () => Promise; + isOpenCodeInstalled: () => Promise<{ installed: boolean; source: "user-installed" | "bundled" | "missing" }>; storeApiKey: (provider: string, key: string) => Promise; deleteApiKey: (provider: string) => Promise; listApiKeys: () => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 47a8fac46..b81f02f2e 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2950,6 +2950,8 @@ contextBridge.exposeInMainWorld("ade", { }, getOpenCodeRuntimeDiagnostics: async (): Promise => ipcRenderer.invoke(IPC.aiGetOpenCodeRuntimeDiagnostics), + isOpenCodeInstalled: async (): Promise<{ installed: boolean; source: "user-installed" | "bundled" | "missing" }> => + ipcRenderer.invoke(IPC.aiIsOpenCodeInstalled), storeApiKey: async (provider: string, key: string): Promise => clearAround( () => aiStatusCache.clear(), diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelListRow.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelListRow.tsx index 0f150a2be..63f69b64c 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelListRow.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelListRow.tsx @@ -15,7 +15,9 @@ function subProviderLabel(model: ModelDescriptor): string | null { const sub = (model as ModelDescriptor & { subProvider?: string }).subProvider; if (typeof sub === "string" && sub.trim().length) return sub.trim(); if (model.providerRoute === "opencode" && model.openCodeProviderId) { - return `${model.openCodeProviderId} via OpenCode`; + // Rows shown inside the OpenCode rail; "via OpenCode" was redundant. + const id = model.openCodeProviderId; + return id.charAt(0).toUpperCase() + id.slice(1); } return null; } diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx index bc37f2989..1152ea2af 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx @@ -101,6 +101,7 @@ vi.mock("./useProviderAuthStatus", () => ({ useProviderAuthStatus: () => ({ status: { ...providerAuthStatusInternal }, opencodeBinaryInstalled: opencodeBinaryInstalledInternal, + binaryProbed: true, loaded: true, }), })); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx index b13392a42..dda3649bf 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx @@ -59,7 +59,11 @@ function modelSubProvider(model: ModelDescriptor): string { const sub = (model as ModelDescriptor & { subProvider?: string }).subProvider; if (typeof sub === "string" && sub.trim().length) return sub.trim(); if (model.providerRoute === "opencode" && model.openCodeProviderId) { - return `${model.openCodeProviderId} via OpenCode`; + // Sub-header text used in grouped lists. Already inside the OpenCode rail + // when this fires, so "via OpenCode" was redundant — show the underlying + // provider name only. Title-case so "anthropic" reads as "Anthropic". + const id = model.openCodeProviderId; + return id.charAt(0).toUpperCase() + id.slice(1); } return ""; } @@ -105,15 +109,13 @@ export const ModelPickerContent = memo(function ModelPickerContent({ return internalAuth.status; }, [providerAuthStatus, internalAuth.status]); - // Treat OpenCode as installed until the auth status loads — the initial - // store value is `false` and the fetch can take ~2s on cold-start while - // the OpenCode server boots and provider.list responds. Showing the - // "Install OpenCode" empty state during that window is wrong for users - // who DO have it installed; we only want to flip to the empty state - // after a real "loaded && not installed" signal. - const opencodeBinaryInstalled = internalAuth.loaded - ? internalAuth.opencodeBinaryInstalled - : true; + // The cheap binary probe answers "is OpenCode installed?" in ms (separate + // from the slow full getStatus probe). Until that lands we don't render + // the OpenCode-required empty state — we just show an empty list so we + // don't make a false "Install OpenCode" claim during the brief unknown + // window. + const opencodeBinaryInstalled = internalAuth.opencodeBinaryInstalled; + const opencodeBinaryKnown = internalAuth.binaryProbed; const isOpencodeRequiredFamily = useCallback( (family: ProviderFamily): family is "opencode" | "ollama" | "lmstudio" => family === "opencode" || family === "ollama" || family === "lmstudio", @@ -193,9 +195,12 @@ export const ModelPickerContent = memo(function ModelPickerContent({ const filterAvailable = useCallback( (m: ModelDescriptor): boolean => { // Models for opencode/ollama/lmstudio require the OpenCode CLI runtime. - // When OpenCode isn't installed, hide them across all views so the panes - // surface a unified "Install OpenCode" empty state. - if (!opencodeBinaryInstalled && isOpencodeRequiredFamily(m.family)) { + // When the cheap binary check has completed AND OpenCode isn't installed, + // hide them so the panes surface the "Install OpenCode" empty state. + // While the probe is still in-flight (binaryKnown === false) we don't + // hide anything — the alternative would flash "Install OpenCode" briefly + // for users who do have it. + if (opencodeBinaryKnown && !opencodeBinaryInstalled && isOpencodeRequiredFamily(m.family)) { return false; } if (!authOnly) return true; @@ -205,7 +210,7 @@ export const ModelPickerContent = memo(function ModelPickerContent({ } return isAvailable(m.id); }, - [authOnly, effectiveAuth, familyIsReady, isAvailable, isOpencodeRequiredFamily, opencodeBinaryInstalled], + [authOnly, effectiveAuth, familyIsReady, isAvailable, isOpencodeRequiredFamily, opencodeBinaryInstalled, opencodeBinaryKnown], ); const searchActive = query.trim().length > 0; @@ -398,6 +403,7 @@ export const ModelPickerContent = memo(function ModelPickerContent({ const activeFamilyNeedsOpencode = activeProviderFamily != null && isOpencodeRequiredFamily(activeProviderFamily) + && opencodeBinaryKnown && !opencodeBinaryInstalled; const showSetupBanner = activeProviderFamily != null @@ -531,6 +537,7 @@ export const ModelPickerContent = memo(function ModelPickerContent({ selection={selection} searchActive={searchActive} opencodeBinaryInstalled={opencodeBinaryInstalled} + opencodeBinaryKnown={opencodeBinaryKnown} {...(onOpenSignIn ? { onOpenSignIn } : {})} /> ) : ( @@ -593,17 +600,20 @@ function EmptyState({ selection, searchActive, opencodeBinaryInstalled, + opencodeBinaryKnown, onOpenSignIn, }: { selection: RailSelection; searchActive: boolean; opencodeBinaryInstalled: boolean; + opencodeBinaryKnown: boolean; onOpenSignIn?: () => void; }) { if (!searchActive && selection !== "favorites" && selection !== "recents") { const family = selection.slice("provider:".length) as ProviderFamily; if ( - !opencodeBinaryInstalled + opencodeBinaryKnown + && !opencodeBinaryInstalled && (family === "opencode" || family === "ollama" || family === "lmstudio") ) { return ( diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts index 77ffb3e47..3813b4840 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts @@ -78,15 +78,21 @@ export function createUnknownModelPlaceholder(modelId: string): ModelDescriptor } /** - * Models reached via the OpenCode runtime are family-mapped to their underlying - * vendor ("anthropic", "openai", etc.) so they show the correct logo elsewhere - * in the app. The picker groups them under a single "OpenCode" rail entry, so - * we override `family` to `"opencode"` here while keeping `openCodeProviderId` - * intact for sub-provider sub-headers and row logos. + * Models reached via the OpenCode runtime are family-mapped for picker display: + * - Local-provider models (lmstudio, ollama) keep their native family so they + * appear in their own rail entry, not lumped under OpenCode. Routing through + * OpenCode is an implementation detail; semantically the user thinks of these + * as LM Studio / Ollama models. + * - Cloud-provider models routed through OpenCode (e.g. anthropic-via-opencode) + * get rebucketed to family "opencode" so they group under the OpenCode rail + * alongside other OpenCode-routed providers. openCodeProviderId stays intact + * for the sub-provider header + row logo. */ function rebucketOpenCodeFamily(model: ModelDescriptor): ModelDescriptor { if (model.providerRoute !== "opencode") return model; if (model.family === "opencode") return model; + // Local providers retain their own family so they get their own rail. + if (model.family === "lmstudio" || model.family === "ollama") return model; return { ...model, family: "opencode" }; } diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts index 135f4bd6b..a3f340184 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts @@ -9,19 +9,24 @@ type AuthStatusMap = Partial>; type ProviderAuthStore = { status: AuthStatusMap; opencodeBinaryInstalled: boolean; + /** True once the cheap binary check has completed; independent of the slower full-status fetch. */ + binaryProbed: boolean; loaded: boolean; inFlight: Promise | null; setStatus: (status: AuthStatusMap, opencodeBinaryInstalled: boolean) => void; + setBinaryProbed: (installed: boolean) => void; setInFlight: (promise: Promise | null) => void; }; const useProviderAuthStore = create((set) => ({ status: {}, opencodeBinaryInstalled: false, + binaryProbed: false, loaded: false, inFlight: null, setStatus: (status, opencodeBinaryInstalled) => - set({ status, opencodeBinaryInstalled, loaded: true, inFlight: null }), + set({ status, opencodeBinaryInstalled, binaryProbed: true, loaded: true, inFlight: null }), + setBinaryProbed: (installed) => set({ opencodeBinaryInstalled: installed, binaryProbed: true }), setInFlight: (promise) => set({ inFlight: promise }), })); @@ -64,6 +69,24 @@ export function opencodeBinaryInstalledFromStatus(status: { opencodeBinaryInstal return status.opencodeBinaryInstalled === true; } +/** + * Cheap synchronous-ish IPC call that only resolves whether the OpenCode + * binary exists on PATH/known dirs. Returns in milliseconds — no server boot, + * no probe. Used to flip the OpenCode-gated empty state without waiting on + * the slow getStatus() roundtrip. + */ +async function probeBinary(): Promise { + const ade = (window as unknown as { ade?: { ai?: { isOpenCodeInstalled?: () => Promise<{ installed: boolean }> } } }).ade; + const check = ade?.ai?.isOpenCodeInstalled; + if (typeof check !== "function") return; + try { + const result = await check(); + useProviderAuthStore.getState().setBinaryProbed(result.installed === true); + } catch { + /* leave binaryProbed false; consumers fall back to the full fetch */ + } +} + async function fetchStatus(): Promise { const store = useProviderAuthStore.getState(); if (store.inFlight) return store.inFlight; @@ -93,19 +116,24 @@ async function fetchStatus(): Promise { export function useProviderAuthStatus(): { status: AuthStatusMap; opencodeBinaryInstalled: boolean; + /** True once we have a definitive answer for opencodeBinaryInstalled (cheap probe done). */ + binaryProbed: boolean; loaded: boolean; } { const slice = useProviderAuthStore( useShallow((state) => ({ status: state.status, opencodeBinaryInstalled: state.opencodeBinaryInstalled, + binaryProbed: state.binaryProbed, loaded: state.loaded, })), ); - // Refetch on every mount — picker mounts on popover open, so the user - // signing into a provider in Settings then reopening the picker gets fresh - // status without polling. Concurrent calls are dedup'd via `inFlight`. + // Run BOTH on mount in parallel: cheap binary probe (ms) gives us the + // opencode-installed signal immediately so the picker doesn't flash the + // wrong empty state; the full fetchStatus continues in the background to + // populate connected providers + their model lists. useEffect(() => { + void probeBinary(); void fetchStatus(); }, []); return slice; diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 8c5bfdb34..39ccb7758 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -452,6 +452,7 @@ export const IPC = { keybindingsGet: "ade.keybindings.get", keybindingsSet: "ade.keybindings.set", aiGetStatus: "ade.ai.getStatus", + aiIsOpenCodeInstalled: "ade.ai.isOpenCodeInstalled", aiGetOpenCodeRuntimeDiagnostics: "ade.ai.getOpenCodeRuntimeDiagnostics", aiStoreApiKey: "ade.ai.storeApiKey", aiDeleteApiKey: "ade.ai.deleteApiKey", From 176ec5c9320d862238c44bfb3281377c6561837d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 18 May 2026 17:24:33 -0400 Subject: [PATCH 13/14] ship: prepare lane for review --- apps/ade-cli/src/adeRpcServer.ts | 12 +- apps/ade-cli/src/bootstrap.ts | 2 + apps/ade-cli/src/services/modelPickerStore.ts | 16 + .../services/sync/syncRemoteCommandService.ts | 68 +++- apps/ade-cli/src/services/sync/syncService.ts | 9 + apps/ade-cli/src/tuiClient/adeApi.ts | 9 + apps/ade-cli/src/tuiClient/app.tsx | 291 ++++++++++---- .../ModelPicker/ModelPickerPane.tsx | 59 ++- .../ModelPicker/modelPickerLayout.ts | 86 ++++- .../tuiClient/components/ModelPicker/types.ts | 8 + .../src/tuiClient/components/RightPane.tsx | 27 +- apps/ade-cli/src/tuiClient/theme.ts | 4 + apps/ade-cli/src/tuiClient/types.ts | 3 +- .../main/services/adeActions/registry.test.ts | 21 + .../src/main/services/adeActions/registry.ts | 3 + .../services/ai/aiIntegrationService.test.ts | 15 +- .../main/services/ai/aiIntegrationService.ts | 26 +- .../services/chat/agentChatService.test.ts | 317 ++++++++-------- .../main/services/chat/agentChatService.ts | 359 +++++++++++++++--- .../chat/cursorModelsDiscovery.test.ts | 70 +++- .../services/chat/cursorModelsDiscovery.ts | 291 ++++++++++++-- .../src/main/services/chat/cursorSdkPool.ts | 3 + .../main/services/chat/cursorSdkProtocol.ts | 9 + .../src/main/services/chat/cursorSdkWorker.ts | 48 ++- .../chat/droidModelsDiscovery.test.ts | 31 ++ .../services/chat/droidModelsDiscovery.ts | 66 +++- .../main/services/chat/droidSdkProtocol.ts | 3 +- .../src/main/services/ipc/registerIpc.ts | 5 + .../opencode/openCodeBinaryManager.test.ts | 86 +++++ .../opencode/openCodeBinaryManager.ts | 15 +- .../opencode/openCodeInventory.test.ts | 101 ++++- .../services/opencode/openCodeInventory.ts | 176 +++++++-- .../sync/syncRemoteCommandService.test.ts | 2 +- apps/desktop/src/preload/global.d.ts | 3 + apps/desktop/src/preload/preload.test.ts | 53 +++ apps/desktop/src/preload/preload.ts | 24 +- apps/desktop/src/renderer/browserMock.ts | 1 + .../components/chat/AgentChatComposer.tsx | 11 +- .../chat/AgentChatPane.submit.test.tsx | 134 ++++--- .../components/chat/AgentChatPane.tsx | 70 +--- .../settings/ProvidersSection.test.tsx | 2 + .../shared/ModelPicker/ModelPicker.test.tsx | 224 ++++++++++- .../shared/ModelPicker/ModelPicker.tsx | 139 ++++++- .../shared/ModelPicker/ModelPickerContent.tsx | 180 ++++++--- .../ReasoningEffortPicker.test.tsx | 58 +++ .../ModelPicker/ReasoningEffortPicker.tsx | 5 +- .../shared/ModelPicker/modelCatalog.test.ts | 65 +++- .../shared/ModelPicker/modelCatalog.ts | 104 ++++- .../shared/ModelPicker/runtimeCatalogCache.ts | 107 ++++++ .../ModelPicker/useProviderAuthStatus.ts | 33 +- apps/desktop/src/shared/ipc.ts | 1 + apps/desktop/src/shared/modelCatalog.ts | 46 +-- apps/desktop/src/shared/modelRegistry.ts | 30 +- apps/desktop/src/shared/types/chat.ts | 21 + apps/desktop/src/shared/types/sync.ts | 7 +- apps/ios/ADE/Models/RemoteModels.swift | 11 +- apps/ios/ADE/Services/SyncService.swift | 56 ++- .../ios/ADE/Views/Work/WorkModelCatalog.swift | 83 ++-- .../ADE/Views/Work/WorkModelPickerSheet.swift | 253 ++++++++---- .../WorkSessionSettingsSheet+Actions.swift | 2 +- .../Views/Work/WorkSessionSettingsSheet.swift | 2 +- .../Work/WorkStatusAndFormattingHelpers.swift | 6 + docs/ARCHITECTURE.md | 8 +- docs/features/ade-code/README.md | 6 +- docs/features/chat/README.md | 55 ++- docs/features/chat/agent-routing.md | 6 +- docs/features/chat/composer-and-ui.md | 58 ++- .../sync-and-multi-device/remote-commands.md | 10 +- 68 files changed, 3273 insertions(+), 842 deletions(-) create mode 100644 apps/desktop/src/main/services/opencode/openCodeBinaryManager.test.ts create mode 100644 apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 1a4b9b044..0810bfba3 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -67,18 +67,12 @@ import { import type { AgentChatPermissionMode, TerminalSessionSummary } from "../../desktop/src/shared/types"; import type { AdeRuntime } from "./bootstrap"; import { JsonRpcError, JsonRpcErrorCode, type JsonRpcHandler, type JsonRpcRequest } from "./jsonrpc"; -import { createModelPickerStore, type ModelPickerStore } from "./services/modelPickerStore"; +import { getSharedModelPickerStore } from "./services/modelPickerStore"; // Cross-surface (desktop + TUI + iOS) model picker favorites & recents. -// Process-singleton so concurrent JSON-RPC sessions see the same in-memory state. +// Backed by a process-wide singleton (see services/modelPickerStore.ts) so the +// JSON-RPC server and the sync host share the same in-memory state. // Persistence path is ~/.ade/modelPicker.json — see modelPickerStore.ts for schema. -let sharedModelPickerStore: ModelPickerStore | null = null; -function getSharedModelPickerStore(): ModelPickerStore { - if (!sharedModelPickerStore) { - sharedModelPickerStore = createModelPickerStore(); - } - return sharedModelPickerStore; -} type ToolSpec = { name: string; diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index bdd904f1b..bcfbbee1c 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -65,6 +65,7 @@ import { initApiKeyStore } from "../../desktop/src/main/services/ai/apiKeyStore" import { createMissionBudgetService } from "../../desktop/src/main/services/orchestrator/missionBudgetService"; import type { createSyncService } from "./services/sync/syncService"; import type { createSyncHostService, SyncRuntimeKind } from "./services/sync/syncHostService"; +import { getSharedModelPickerStore } from "./services/modelPickerStore"; import type { createAutomationIngressService } from "../../desktop/src/main/services/automations/automationIngressService"; import type { createGithubService } from "../../desktop/src/main/services/github/githubService"; import { createFeedbackReporterService } from "../../desktop/src/main/services/feedback/feedbackReporterService"; @@ -1088,6 +1089,7 @@ export async function createAdeRuntime(args: { forceHostRole: resolvedArgs.syncRuntime.forceHostRole ?? true, projectCatalogProvider: resolvedArgs.syncRuntime.projectCatalogProvider, remoteCommandExecutor: resolvedArgs.syncRuntime.remoteCommandExecutor, + getModelPickerStore: () => getSharedModelPickerStore(), onStatusChanged: (snapshot) => pushEvent("runtime", { type: "sync-status", snapshot }), }); } diff --git a/apps/ade-cli/src/services/modelPickerStore.ts b/apps/ade-cli/src/services/modelPickerStore.ts index ee0fa0aac..4a89a5e8f 100644 --- a/apps/ade-cli/src/services/modelPickerStore.ts +++ b/apps/ade-cli/src/services/modelPickerStore.ts @@ -137,3 +137,19 @@ export function createModelPickerStore(options: CreateModelPickerStoreOptions = } export const MODEL_PICKER_MAX_RECENTS = MAX_RECENTS; + +// Process-wide singleton shared across the JSON-RPC server (adeRpcServer) and +// the sync host (syncRemoteCommandService) so favorites + recents stay +// consistent for desktop/TUI/iOS without coordination races. Lazy-init so +// tests can avoid touching the user's real ~/.ade/modelPicker.json by +// supplying their own store instance via `createModelPickerStore`. +let sharedStoreInstance: ModelPickerStore | null = null; +export function getSharedModelPickerStore(): ModelPickerStore { + if (!sharedStoreInstance) { + sharedStoreInstance = createModelPickerStore(); + } + return sharedStoreInstance; +} +export function resetSharedModelPickerStoreForTests(): void { + sharedStoreInstance = null; +} diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 1040e6130..bb3bcfc48 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -6,6 +6,9 @@ import type { AgentChatFileRef, AgentChatGetSummaryArgs, AgentChatListArgs, + AgentChatModelCatalogArgs, + AgentChatModelCatalogMode, + AgentChatModelCatalogRefreshProvider, AgentChatProvider, AgentChatRespondToInputArgs, AgentChatSendArgs, @@ -150,6 +153,7 @@ import type { PathToMergeOrchestrator } from "../../../../desktop/src/main/servi import type { createQueueLandingService } from "../../../../desktop/src/main/services/prs/queueLandingService"; import type { createPtyService } from "../../../../desktop/src/main/services/pty/ptyService"; import type { createSessionService } from "../../../../desktop/src/main/services/sessions/sessionService"; +import { getSharedModelPickerStore, type ModelPickerStore } from "../modelPickerStore"; type SyncRemoteCommandServiceArgs = { laneService: ReturnType; @@ -191,6 +195,14 @@ type SyncRemoteCommandServiceArgs = { laneTemplateService?: ReturnType | null; rebaseSuggestionService?: ReturnType | null; autoRebaseService?: ReturnType | null; + /** + * Lazy accessor for the process-wide model picker store (favorites + recents + * persisted to `~/.ade/modelPicker.json`). iOS hits these via the + * `modelPicker.*` sync commands so favorites/recents stay in sync with + * desktop + TUI. Optional so older callers without the accessor wired keep + * compiling — handlers reject with a clear error when missing. + */ + getModelPickerStore?: () => ModelPickerStore | null; logger: Logger; }; @@ -938,6 +950,23 @@ function parseChatModelsArgs(value: Record): { provider: AgentC }; } +function parseChatModelCatalogArgs(value: Record): AgentChatModelCatalogArgs { + const mode = asTrimmedString(value.mode) as AgentChatModelCatalogMode | null; + const refreshProvider = asTrimmedString(value.refreshProvider) as AgentChatModelCatalogRefreshProvider | null; + return { + ...(mode === "cached" || mode === "refresh-stale" || mode === "force" ? { mode } : {}), + ...( + refreshProvider === "opencode" + || refreshProvider === "cursor" + || refreshProvider === "droid" + || refreshProvider === "lmstudio" + || refreshProvider === "ollama" + ? { refreshProvider } + : {} + ), + }; +} + function requirePrId(value: Record, action: string): string { return requireString(value.prId, `${action} requires prId.`); } @@ -2000,8 +2029,43 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg }); register("chat.models", { viewerAllowed: true }, async (payload) => requireService(args.agentChatService, "Agent chat service not available.").getAvailableModels(parseChatModelsArgs(payload))); - register("chat.modelCatalog", { viewerAllowed: true }, async () => - requireService(args.agentChatService, "Agent chat service not available.").getModelCatalog()); + register("chat.modelCatalog", { viewerAllowed: true }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").getModelCatalog(parseChatModelCatalogArgs(payload))); + + // Cross-surface ModelPicker favorites + recents — see modelPickerStore.ts. + // Mirrors the direct JSON-RPC `modelPicker.*` methods on adeRpcServer so iOS + // (which routes through the WebSocket sync command envelope) shares the + // same persisted store at ~/.ade/modelPicker.json. + // Falls back to the process-wide singleton when no accessor is wired — + // older bootstraps (tests, embedded uses) still get a working store without + // having to thread the accessor explicitly. + const requireModelPickerStore = (): ModelPickerStore => + args.getModelPickerStore?.() ?? getSharedModelPickerStore(); + register("modelPicker.getFavorites", { viewerAllowed: true }, async () => ({ + favorites: requireModelPickerStore().getFavorites(), + }), "runtime"); + register("modelPicker.setFavorites", { viewerAllowed: true }, async (payload) => { + const rawFavorites = (payload as { favorites?: unknown }).favorites; + const favoritesInput = Array.isArray(rawFavorites) + ? rawFavorites.filter((entry): entry is string => typeof entry === "string") + : []; + return { favorites: requireModelPickerStore().setFavorites(favoritesInput) }; + }, "runtime"); + register("modelPicker.toggleFavorite", { viewerAllowed: true }, async (payload) => { + const modelId = typeof (payload as { modelId?: unknown }).modelId === "string" + ? ((payload as { modelId?: string }).modelId as string) + : ""; + return requireModelPickerStore().toggleFavorite(modelId); + }, "runtime"); + register("modelPicker.getRecents", { viewerAllowed: true }, async () => ({ + recents: requireModelPickerStore().getRecents(), + }), "runtime"); + register("modelPicker.pushRecent", { viewerAllowed: true }, async (payload) => { + const modelId = typeof (payload as { modelId?: unknown }).modelId === "string" + ? ((payload as { modelId?: string }).modelId as string) + : ""; + return { recents: requireModelPickerStore().pushRecent(modelId) }; + }, "runtime"); register("cto.getRoster", { viewerAllowed: true }, async () => { const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index 76945c9ba..e3d7520c4 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -63,6 +63,7 @@ import { createSyncPeerService } from "./syncPeerService"; import { createSyncPinStore } from "./syncPinStore"; import { DEFAULT_SYNC_HOST_PORT } from "./syncProtocol"; import { createSyncRemoteCommandService, type SyncRemoteCommandService } from "./syncRemoteCommandService"; +import type { ModelPickerStore } from "../modelPickerStore"; type SyncServiceArgs = { db: AdeDb; @@ -141,6 +142,13 @@ type SyncServiceArgs = { ) => Promise; }; remoteCommandExecutor?: Pick; + /** + * Lazy accessor for the process-wide model picker store. iOS uses the + * `modelPicker.*` sync commands to share favorites + recents with desktop + * and the TUI; the store is a process singleton so all surfaces see the + * same in-memory state and persist to `~/.ade/modelPicker.json`. + */ + getModelPickerStore?: () => ModelPickerStore | null; }; const DRAFT_FILE = "sync-peer-draft.json"; @@ -558,6 +566,7 @@ export function createSyncService(args: SyncServiceArgs) { laneTemplateService: args.laneTemplateService, rebaseSuggestionService: args.rebaseSuggestionService ?? undefined, autoRebaseService: args.autoRebaseService ?? undefined, + getModelPickerStore: args.getModelPickerStore, logger: args.logger, }); diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index f12438396..b3e43dedd 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -21,6 +21,8 @@ import type { AgentChatEventEnvelope, AgentChatFileRef, AgentChatInteractionMode, + AgentChatModelCatalog, + AgentChatModelCatalogArgs, AgentChatModelInfo, AgentChatOpenCodePermissionMode, AgentChatPermissionMode, @@ -317,6 +319,13 @@ export async function getAvailableModels( }); } +export async function getModelCatalog( + connection: AdeCodeConnection, + args: AgentChatModelCatalogArgs = {}, +): Promise { + return await connection.action("chat", "modelCatalog", args); +} + export async function getAiSettingsStatus( connection: AdeCodeConnection, args: { force?: boolean; refreshOpenCodeInventory?: boolean } = {}, diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 5937099ab..01cb1c7c6 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -11,6 +11,7 @@ import { modelSupportsFastMode, resolveModelDescriptor, resolveProviderGroupForModel, + type ModelProviderGroup, } from "../../../desktop/src/shared/modelRegistry"; import { resolveClaudeCliModelForLaunch } from "../../../desktop/src/shared/cliLaunch"; import { CURSOR_AVAILABLE_MODE_IDS, CURSOR_MODE_LABELS } from "../../../desktop/src/shared/cursorModes"; @@ -22,9 +23,12 @@ import type { AgentChatClaudePlugin, AgentChatReloadClaudePluginsResult, AgentChatContextUsage, - AgentChatEventEnvelope, - AgentChatFileRef, - AgentChatModelInfo, + AgentChatEventEnvelope, + AgentChatFileRef, + AgentChatModelCatalog, + AgentChatModelCatalogModel, + AgentChatModelCatalogRefreshProvider, + AgentChatModelInfo, AgentChatPermissionMode, AgentChatSessionSummary, AgentChatSlashCommand, @@ -46,8 +50,9 @@ import { getAvailableModels, getAiSettingsStatus, getChatHistory, - getContextUsage, - getModelPickerFavorites, + getContextUsage, + getModelCatalog, + getModelPickerFavorites, getModelPickerRecents, pushModelPickerRecent, toggleModelPickerFavorite, @@ -158,9 +163,13 @@ const PROVIDER_OPTIONS: Array<{ value: AdeCodeProvider; label: string }> = [ { value: "cursor", label: "Cursor" }, { value: "droid", label: "Droid" }, { value: "opencode", label: "OpenCode" }, + { value: "ollama", label: "Ollama" }, + { value: "lmstudio", label: "LM Studio" }, ]; const PROVIDERS = new Set(PROVIDER_OPTIONS.map((provider) => provider.value)); const CODEX_PRESETS = ["default", "plan", "full-auto", "config-toml"] as const; +const MODEL_CATALOG_CLIENT_REFRESH_TTL_MS = 5 * 60_000; +const MODEL_CATALOG_LOCAL_CLIENT_REFRESH_TTL_MS = 30_000; const CLAUDE_PERMISSION_OPTIONS = ["default", "auto", "plan", "acceptEdits", "bypassPermissions"] as const; const OPENCODE_PERMISSION_OPTIONS = ["plan", "edit", "full-auto"] as const; const DROID_PERMISSION_OPTIONS = ["read-only", "auto-low", "auto-medium", "auto-high"] as const; @@ -264,6 +273,16 @@ function normalizeProvider(value: string | null | undefined): AdeCodeProvider { return PROVIDERS.has(value as AdeCodeProvider) ? value as AdeCodeProvider : "codex"; } +function runtimeProviderForUiProvider(provider: AdeCodeProvider): ModelProviderGroup { + return provider === "ollama" || provider === "lmstudio" ? "opencode" : provider; +} + +function modelCatalogClientRefreshTtlMs(provider?: AgentChatModelCatalogRefreshProvider): number { + return provider === "lmstudio" || provider === "ollama" + ? MODEL_CATALOG_LOCAL_CLIENT_REFRESH_TTL_MS + : MODEL_CATALOG_CLIENT_REFRESH_TTL_MS; +} + function firstReasoningEffortForModel(model: AgentChatModelInfo | null | undefined, provider: AdeCodeProvider): string | null { const efforts = model?.reasoningEfforts?.map((entry) => entry.effort).filter(Boolean) ?? []; if (efforts.includes(DEFAULT_CODEX_REASONING_EFFORT)) return DEFAULT_CODEX_REASONING_EFFORT; @@ -289,8 +308,9 @@ function modelStatePatchForModel(provider: AdeCodeProvider, model: AgentChatMode } function fallbackModelStatePatch(provider: AdeCodeProvider): Pick { - const descriptor = getDefaultModelDescriptor(provider) - ?? listModelDescriptorsForProvider(provider)[0] + const registryProvider = provider === "ollama" || provider === "lmstudio" ? "opencode" : provider; + const descriptor = getDefaultModelDescriptor(registryProvider) + ?? listModelDescriptorsForProvider(registryProvider)[0] ?? getDefaultModelDescriptor("codex"); return { provider, @@ -302,22 +322,24 @@ function fallbackModelStatePatch(provider: AdeCodeProvider): Pick ({ id: descriptor.id, modelId: descriptor.id, displayName: descriptor.displayName, isDefault: descriptor.id === getDefaultModelDescriptor(provider)?.id, reasoningEfforts: descriptor.reasoningTiers?.map((effort) => ({ effort, description: effort })), + ...(descriptor.serviceTiers?.length ? { serviceTiers: descriptor.serviceTiers } : {}), })); } function modelReasoningEfforts(modelState: AdeCodeModelState, models: AgentChatModelInfo[]): string[] { - if (modelState.provider === "cursor" || modelState.provider === "droid") return []; const model = models.find((entry) => entry.id === modelState.modelId || entry.modelId === modelState.modelId); const fromModel = model?.reasoningEfforts?.map((entry) => entry.effort).filter(Boolean) ?? []; if (fromModel.length) return fromModel; const descriptor = modelState.modelId ? getModelById(modelState.modelId) : undefined; - return descriptor?.reasoningTiers?.length ? descriptor.reasoningTiers : EFFORTS; + if (descriptor?.reasoningTiers?.length) return descriptor.reasoningTiers; + return modelState.provider === "codex" ? EFFORTS : []; } function resolveCodexPreset(modelState: AdeCodeModelState): CodexPreset | "custom" { @@ -1088,7 +1110,10 @@ function buildSetupRows(args: { }): SetupPaneRow[] { const efforts = modelReasoningEfforts(args.modelState, args.models); const descriptor = args.modelState.modelId ? getModelById(args.modelState.modelId) : undefined; - const fastSupported = args.modelState.provider === "codex" && modelSupportsFastMode(descriptor); + const activeModel = args.models.find((entry) => entry.id === args.modelState.modelId || entry.modelId === args.modelState.modelId); + const fastSupported = + Boolean(activeModel?.serviceTiers?.some((tier) => tier.trim().toLowerCase() === "fast")) + || modelSupportsFastMode(descriptor); const rows: SetupPaneRow[] = [ { kind: "provider", @@ -1189,7 +1214,9 @@ function defaultModelPickerSelectionIndex(rows: SetupPaneRow[]): number { return defaultSetupSelectionIndex(rows); } -function providerConnectionDetail(status: AiSettingsStatus | null, provider: Exclude): ProviderReadinessRow { +type ConnectionStatusProvider = Extract; + +function providerConnectionDetail(status: AiSettingsStatus | null, provider: ConnectionStatusProvider): ProviderReadinessRow { const connection = status?.providerConnections?.[provider]; const modelCount = status?.models?.[provider]?.length ?? 0; if (connection?.runtimeAvailable) { @@ -1906,8 +1933,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const [inlineRowFocus, setInlineRowFocus] = useState<{ cell: 'provider' | 'model' | 'reasoning' | 'permission' | 'subagents' | null }>({ cell: null }); const inlineRowFocused = inlineRowFocus.cell !== null; // Cross-surface model picker favorites/recents — authoritative copy lives in ade-cli. - const [modelPickerFavorites, setModelPickerFavorites] = useState([]); - const [modelPickerRecents, setModelPickerRecents] = useState([]); + const [modelPickerFavorites, setModelPickerFavorites] = useState([]); + const [modelPickerRecents, setModelPickerRecents] = useState([]); + const [modelCatalog, setModelCatalog] = useState(null); const connectionRef = useRef(null); const activeLaneIdRef = useRef(null); @@ -1965,7 +1993,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const ctrlCExitArmedUntilRef = useRef(0); const ctrlCExitTimerRef = useRef(null); const loadedSessionIdRef = useRef(null); - const providerModelsCacheRef = useRef>(new Map()); + const providerModelsCacheRef = useRef>(new Map()); + const modelCatalogRef = useRef(null); + const modelCatalogProviderRefreshedAtRef = useRef>(new Map()); const pendingModelCommitTimerRef = useRef(null); const pendingModelCommitStateRef = useRef(null); @@ -3493,7 +3523,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setAiStatusCheckedAt(new Date().toISOString()); }, []); - const loadProviderModels = useCallback(async (provider: AdeCodeProvider, options: { applyDefault?: boolean; force?: boolean } = {}) => { + const loadProviderModels = useCallback(async (provider: AdeCodeProvider, options: { applyDefault?: boolean; force?: boolean } = {}) => { const conn = connectionRef.current; const cached = providerModelsCacheRef.current.get(provider); let nextModels = cached ?? registryModelsForProvider(provider); @@ -3513,8 +3543,49 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } ...(model ? modelStatePatchForModel(provider, model) : fallbackModelStatePatch(provider)), })); } - return nextModels; - }, []); + return nextModels; + }, []); + + const refreshModelCatalog = useCallback(async (options: { refreshProvider?: AgentChatModelCatalogRefreshProvider } = {}) => { + const conn = connectionRef.current; + if (!conn) return modelCatalogRef.current; + if (!options.refreshProvider && modelCatalogRef.current) { + setModelCatalog(modelCatalogRef.current); + return modelCatalogRef.current; + } + if (options.refreshProvider && modelCatalogRef.current) { + const refreshedAt = modelCatalogProviderRefreshedAtRef.current.get(options.refreshProvider); + if (refreshedAt && Date.now() - refreshedAt <= modelCatalogClientRefreshTtlMs(options.refreshProvider)) { + setModelCatalog(modelCatalogRef.current); + return modelCatalogRef.current; + } + } + try { + const catalog = await getModelCatalog(conn, { + mode: options.refreshProvider ? "refresh-stale" : "cached", + ...(options.refreshProvider ? { refreshProvider: options.refreshProvider } : {}), + }); + modelCatalogRef.current = catalog; + setModelCatalog(catalog); + if (options.refreshProvider && catalog.stale !== true) { + modelCatalogProviderRefreshedAtRef.current.set(options.refreshProvider, Date.now()); + } + if (options.refreshProvider && catalog.stale === true) { + void getModelCatalog(conn, { + mode: "force", + refreshProvider: options.refreshProvider, + }).then((freshCatalog) => { + if (connectionRef.current !== conn) return; + modelCatalogRef.current = freshCatalog; + modelCatalogProviderRefreshedAtRef.current.set(options.refreshProvider!, Date.now()); + setModelCatalog(freshCatalog); + }).catch(() => undefined); + } + return catalog; + } catch { + return modelCatalogRef.current; + } + }, []); const openForm = useCallback((content: Extract) => { const previousPane = activePaneRef.current; @@ -3656,20 +3727,23 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } // Right-pane model picker — replaces the inline-row focus path when launched // via /model or new-chat. Reuses the same data the inline row uses (models) // plus favorites/recents sourced from ade-cli for cross-surface sync. - const openModelPicker = useCallback( - (options: { surface?: "chat" | "new-chat" } = {}) => { - const surface = options.surface ?? "chat"; + const openModelPicker = useCallback( + (options: { surface?: "chat" | "new-chat" } = {}) => { + void refreshModelCatalog(); + const surface = options.surface ?? "chat"; // Build a starter selection from current activeModelId/recents so the // picker opens with relevant content already filtered. const provider = modelState.provider; - const layoutSeed = buildModelPickerLayout({ - models, - favorites: modelPickerFavorites, + const layoutSeed = buildModelPickerLayout({ + models, + catalog: modelCatalogRef.current ?? modelCatalog, + favorites: modelPickerFavorites, recents: modelPickerRecents, activeModelId: modelState.modelId, - query: "", - selection: { kind: "provider", provider }, - focusedIndex: 0, + query: "", + selection: { kind: "provider", provider }, + providerTabKey: null, + focusedIndex: 0, searchMode: false, }); const selection = defaultSelectionFor( @@ -3681,24 +3755,30 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } kind: "model-picker", surface, query: "", - searchMode: false, - selection, - focusedIndex: 0, + searchMode: false, + selection, + providerTabKey: null, + focusedIndex: 0, }); setRightOpen(true); setPaneFocus("details"); - void refreshAiSetupStatus().catch(() => undefined); - void loadProviderModels(provider, { applyDefault: false }).catch(() => undefined); - }, + void refreshAiSetupStatus().catch(() => undefined); + void loadProviderModels(provider, { applyDefault: false }).catch(() => undefined); + if (provider === "opencode" || provider === "cursor" || provider === "droid" || provider === "lmstudio" || provider === "ollama") { + void refreshModelCatalog({ refreshProvider: provider }); + } + }, [ loadProviderModels, modelPickerFavorites, modelPickerRecents, modelState.modelId, - modelState.provider, - models, - refreshAiSetupStatus, - setPaneFocus, + modelState.provider, + models, + modelCatalog, + refreshAiSetupStatus, + refreshModelCatalog, + setPaneFocus, ], ); @@ -4412,11 +4492,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return null; } const normalized = { ...modelState, ...applyProviderPermissionMode(modelState) }; + const runtimeProvider = runtimeProviderForUiProvider(normalized.provider); const created = await createChatSession({ connection: conn, laneId, title: pendingNewChatTitleRef.current, - provider: normalized.provider, + provider: runtimeProvider, modelId: normalized.modelId, reasoningEffort: normalized.reasoningEffort, codexFastMode: normalized.codexFastMode, @@ -5852,7 +5933,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setChatScrollOffset(0); setError(null); const normalized = { ...modelStateRef.current, ...applyProviderPermissionMode(modelStateRef.current) }; - if (normalized.provider === "claude") { + const runtimeProvider = runtimeProviderForUiProvider(normalized.provider); + if (runtimeProvider === "claude") { const cols = clampTerminalPaneCols(terminalPaneWidth); const terminalRows = claudeTerminalRowsForPane(chatRowBudget); const terminalPrompt = promptTextForTerminal(text, promptAttachments); @@ -5872,7 +5954,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } connection: conn, laneId, title: pendingNewChatTitleRef.current, - provider: normalized.provider, + provider: runtimeProvider, modelId: normalized.modelId, reasoningEffort: normalized.reasoningEffort, codexFastMode: normalized.codexFastMode, @@ -5941,13 +6023,28 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } // Commit a model picked in the right-pane ModelPicker into the current chat // model state and push it onto the cross-surface recents list. Defined here // (after applyModelState) so the closure captures a live binding. - const commitModelPickerSelection = useCallback( - (modelId: string) => { - const target = models.find((entry) => (entry.modelId ?? entry.id) === modelId); - if (!target) { - addNotice(`Model ${modelId} is not available right now.`, "error"); - return; - } + const commitModelPickerSelection = useCallback( + (modelId: string) => { + let catalogModel: AgentChatModelCatalogModel | null = null; + for (const group of modelCatalogRef.current?.groups ?? modelCatalog?.groups ?? []) { + for (const provider of group.providers) { + for (const subsection of provider.subsections) { + const found = subsection.models.find((entry) => entry.id === modelId || entry.modelId === modelId); + if (found) { + catalogModel = found; + break; + } + } + if (catalogModel) break; + } + if (catalogModel) break; + } + const target = models.find((entry) => (entry.modelId ?? entry.id) === modelId) + ?? (catalogModel?.isAvailable === true ? catalogModel as AgentChatModelInfo : null); + if (!target) { + addNotice(`Model ${modelId} is not available right now.`, "error"); + return; + } const descriptor = getModelById(modelId); const provider: AdeCodeProvider = descriptor ? normalizeProvider(resolveProviderGroupForModel(descriptor)) @@ -5955,7 +6052,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } applyModelState((prev) => ({ ...prev, ...modelStatePatchForModel(provider, target), - codexFastMode: modelSupportsFastMode(descriptor) ? prev.codexFastMode : false, + codexFastMode: (target.serviceTiers?.some((tier) => tier.trim().toLowerCase() === "fast") || modelSupportsFastMode(descriptor)) + ? prev.codexFastMode + : false, })); setModelPickerRecents((prev) => { const filtered = prev.filter((entry) => entry !== modelId); @@ -5995,8 +6094,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } addNotice(`Model set to ${target.displayName}.`, "success"); }, - [addNotice, applyModelState, lanes, models, modelState.provider, newChatSetupRows, setPaneFocus], - ); + [addNotice, applyModelState, lanes, models, modelCatalog, modelState.provider, newChatSetupRows, setPaneFocus], + ); const selectProvider = useCallback((provider: AdeCodeProvider) => { if (providerLockedRef.current) { @@ -6033,7 +6132,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } applyModelState((prev) => ({ ...prev, ...modelStatePatchForModel(modelState.provider, nextModel), - codexFastMode: modelSupportsFastMode(getModelById(nextModel.modelId ?? nextModel.id)) ? prev.codexFastMode : false, + codexFastMode: (nextModel.serviceTiers?.some((tier) => tier.trim().toLowerCase() === "fast") || modelSupportsFastMode(getModelById(nextModel.modelId ?? nextModel.id))) + ? prev.codexFastMode + : false, })); }, [applyModelState, modelState.modelId, modelState.provider, models]); @@ -6312,7 +6413,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return true; } if (action === "chat:fastMode") { - if (modelState.provider === "codex") { + const activeModel = models.find((entry) => entry.id === modelState.modelId || entry.modelId === modelState.modelId); + const descriptor = modelState.modelId ? getModelById(modelState.modelId) : undefined; + const fastSupported = + Boolean(activeModel?.serviceTiers?.some((tier) => tier.trim().toLowerCase() === "fast")) + || modelSupportsFastMode(descriptor); + if (fastSupported) { applyModelState((prev) => ({ ...prev, codexFastMode: !prev.codexFastMode })); } else if (modelState.provider === "claude") { void submitPrompt("/fast"); @@ -7165,16 +7271,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (pane === "details" && rightOpen && rightPane.kind === "model-picker") { const picker = rightPane; // Re-derive layout each keystroke so we never select stale indexes. - const layout = buildModelPickerLayout({ - models, - favorites: modelPickerFavorites, - recents: modelPickerRecents, - activeModelId: modelState.modelId, - query: picker.query, - selection: picker.selection, - focusedIndex: picker.focusedIndex, - searchMode: picker.searchMode, - }); + const layout = buildModelPickerLayout({ + models, + catalog: modelCatalogRef.current ?? modelCatalog, + favorites: modelPickerFavorites, + recents: modelPickerRecents, + activeModelId: modelState.modelId, + query: picker.query, + selection: picker.selection, + providerTabKey: picker.providerTabKey ?? null, + focusedIndex: picker.focusedIndex, + searchMode: picker.searchMode, + }); if (key.escape) { if (picker.query.length) { @@ -7218,26 +7326,44 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const nextIndex = (layout.railIndex + delta + total) % total; const nextEntry = layout.railEntries[nextIndex]; if (!nextEntry) return; - const nextSelection = - nextEntry.kind === "favorites" - ? ({ kind: "favorites" } as const) - : nextEntry.kind === "recents" - ? ({ kind: "recents" } as const) - : ({ kind: "provider", provider: nextEntry.provider } as const); - setRightPane({ - ...picker, - selection: nextSelection, - focusedIndex: 0, - query: "", + const nextSelection = + nextEntry.kind === "favorites" + ? ({ kind: "favorites" } as const) + : nextEntry.kind === "recents" + ? ({ kind: "recents" } as const) + : ({ kind: "provider", provider: nextEntry.provider } as const); + if (nextSelection.kind === "provider") { + const refreshProvider = + nextSelection.provider === "opencode" || nextSelection.provider === "cursor" || nextSelection.provider === "droid" + || nextSelection.provider === "lmstudio" || nextSelection.provider === "ollama" + ? nextSelection.provider + : null; + if (refreshProvider) void refreshModelCatalog({ refreshProvider }); + } + setRightPane({ + ...picker, + selection: nextSelection, + providerTabKey: null, + focusedIndex: 0, + query: "", searchMode: false, }); return; } - if (key.return) { - const target = layout.entries[layout.focusedIndex]; - if (target) commitModelPickerSelection(target.modelId); - return; - } + if (key.return) { + const target = layout.entries[layout.focusedIndex]; + if (target?.isAvailable) commitModelPickerSelection(target.modelId); + return; + } + if ((input === "[" || input === "]") && layout.providerTabs.length > 1) { + const delta = input === "[" ? -1 : 1; + const nextIndex = (layout.providerTabIndex + delta + layout.providerTabs.length) % layout.providerTabs.length; + const nextTab = layout.providerTabs[nextIndex]; + if (nextTab) { + setRightPane({ ...picker, providerTabKey: nextTab.key, focusedIndex: 0 }); + } + return; + } // 'f' toggles favorite on focused row when not actively editing a search. if (input === "f" && !picker.searchMode && !key.ctrl && !key.meta) { const target = layout.entries[layout.focusedIndex]; @@ -7681,7 +7807,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const showMentionPalette = activeMentionRange != null && mentionSuggestions.length > 0; const showSlashPalette = prompt.startsWith("/") && slashRows.length > 0; const modelStatusOverlayRows = statusRows - + (draftChatActive || (vimModeEnabled && !hideVimModeIndicator) || (modelState.provider === "codex" && modelState.codexFastMode) ? 1 : 0); + + (draftChatActive || (vimModeEnabled && !hideVimModeIndicator) || modelState.codexFastMode ? 1 : 0); const paletteBottomRows = 5 + (promptRows.length - 1) + modelStatusOverlayRows @@ -7781,9 +7907,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } focused={activePane === "details"} activeProvider={activeCommandProvider as AdeCodeProvider} width={rightPaneWidth} - modelPickerInputs={{ - models, - favorites: modelPickerFavorites, + modelPickerInputs={{ + models, + catalog: modelCatalog, + favorites: modelPickerFavorites, recents: modelPickerRecents, activeModelId: modelState.modelId, }} @@ -7864,7 +7991,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } tokenSummary={tokenSummary} approvalActive={pendingApproval?.mode === "approval" && !pendingApproval.highStakes} liveAgentCount={liveAgentCount} - fastMode={modelState.provider === "codex" && modelState.codexFastMode} + fastMode={modelState.codexFastMode} inlineRowFocused={inlineRowFocused} inlineRowCell={inlineRowFocus.cell} providerLocked={providerLocked} diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx b/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx index 8ba6b99f8..28dfb12e4 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx @@ -10,6 +10,8 @@ const PROVIDER_GLYPHS: Record = { opencode: "○", cursor: "◈", droid: "▲", + ollama: "●", + lmstudio: "■", }; function railGlyph(entry: ModelPickerRailEntry): string { @@ -101,7 +103,16 @@ function ModelListRow({ {entry.isFavorite ? " ★" : " ☆"} {" "} @@ -121,6 +132,13 @@ function ModelListRow({ ) : null} + {!entry.isAvailable ? ( + + + Configure provider in ADE/OpenCode + + + ) : null} ); } @@ -172,10 +190,29 @@ export function ModelPickerPane({ width={Math.max(10, Math.floor(innerWidth / 3))} /> - - - {headingLabel} - + + + {headingLabel} + + {state.providerTabs.length > 1 ? ( + + {state.providerTabs.map((tab, index) => { + const selected = index === state.providerTabIndex; + return ( + + {selected ? "[" : " "} + {endTruncate(tab.label, 12)} + {selected ? "]" : " "} + {" "} + + ); + })} + + ) : null} {state.entries.length === 0 ? ( {state.query.trim() @@ -184,7 +221,13 @@ export function ModelPickerPane({ ? "Press f on a model to pin it here." : railEntry?.kind === "recents" ? "Models you switch to will appear here." - : "No models available."} + : railEntry?.kind === "provider" && railEntry.provider === "opencode" + ? "Install OpenCode to use these models." + : railEntry?.kind === "provider" && railEntry.provider === "ollama" + ? "Install OpenCode to use Ollama models." + : railEntry?.kind === "provider" && railEntry.provider === "lmstudio" + ? "Install OpenCode to use LM Studio models." + : "No models available."} ) : ( <> @@ -215,8 +258,8 @@ export function ModelPickerPane({ - ↑↓ pick · ↵ select · tab rail · f fav · / search · esc close - + ↑↓ pick · ↵ select · tab rail · [ ] providers · f fav · / search · esc close + ); diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts index 1b005a834..c7ae9e692 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts @@ -1,6 +1,6 @@ import { scoreModelPickerSearch } from "../../../../../desktop/src/renderer/components/shared/ModelPicker/modelPickerSearch"; import { sortModelItems } from "../../../../../desktop/src/renderer/components/shared/ModelPicker/modelOrdering"; -import type { AgentChatModelInfo } from "../../../../../desktop/src/shared/types/chat"; +import type { AgentChatModelCatalog, AgentChatModelInfo } from "../../../../../desktop/src/shared/types/chat"; import { getModelById, resolveProviderGroupForModel, @@ -20,6 +20,8 @@ const PROVIDER_LABELS: Record = { opencode: "OpenCode", cursor: "Cursor", droid: "Droid", + ollama: "Ollama", + lmstudio: "LM Studio", }; function providerLabel(provider: AdeCodeProvider): string { @@ -33,16 +35,55 @@ function normalizeProvider(value: ProviderFamily | string | undefined): AdeCodeP if (value === "claude" || value === "anthropic") return "claude"; if (value === "codex" || value === "openai") return "codex"; if (value === "opencode") return "opencode"; + if (value === "ollama") return "ollama"; + if (value === "lmstudio") return "lmstudio"; if (value === "cursor") return "cursor"; if (value === "droid" || value === "factory") return "droid"; return "codex"; } +function providerFromCatalogGroup(groupKey: string, fallbackFamily?: string): AdeCodeProvider { + if (groupKey === "claude" || groupKey === "codex" || groupKey === "opencode" || groupKey === "cursor" || groupKey === "droid") { + return groupKey; + } + if (groupKey === "ollama" || groupKey === "lmstudio") return groupKey; + return normalizeProvider(fallbackFamily); +} + function descriptorFor(modelInfo: AgentChatModelInfo): ModelDescriptor | undefined { const id = modelInfo.modelId ?? modelInfo.id; return getModelById(id); } +function entriesFromCatalog( + catalog: AgentChatModelCatalog, + favoritesSet: Set, +): ModelPickerEntry[] { + const entries: ModelPickerEntry[] = []; + const seen = new Set(); + for (const group of catalog.groups ?? []) { + for (const provider of group.providers ?? []) { + for (const subsection of provider.subsections ?? []) { + for (const model of subsection.models ?? []) { + if (seen.has(model.id)) continue; + seen.add(model.id); + entries.push({ + modelId: model.id, + runtimeModelId: model.runtimeModelId || model.id, + displayName: model.displayName, + family: providerFromCatalogGroup(String(model.groupKey || group.key), model.family), + subProvider: model.providerName || provider.displayName || subsection.label || undefined, + subProviderKey: model.providerId || provider.key || subsection.key || undefined, + isFavorite: favoritesSet.has(model.id), + isAvailable: model.isAvailable, + }); + } + } + } + } + return entries; +} + function entryFromModelInfo( modelInfo: AgentChatModelInfo, favoritesSet: Set, @@ -68,18 +109,22 @@ function entryFromModelInfo( export type BuildLayoutInput = { models: AgentChatModelInfo[]; + catalog?: AgentChatModelCatalog | null; favorites: string[]; recents: string[]; activeModelId: string | null; query: string; selection: { kind: "favorites" } | { kind: "recents" } | { kind: "provider"; provider: AdeCodeProvider }; + providerTabKey?: string | null; focusedIndex: number; searchMode: boolean; }; export function buildModelPickerLayout(input: BuildLayoutInput): ModelPickerState { const favoritesSet = new Set(input.favorites); - const allEntries = input.models.map((m) => entryFromModelInfo(m, favoritesSet)); + const allEntries = input.catalog + ? entriesFromCatalog(input.catalog, favoritesSet) + : input.models.map((m) => entryFromModelInfo(m, favoritesSet)); // Providers actually present in the registry-filtered model list. const providersPresent = Array.from( @@ -114,6 +159,37 @@ export function buildModelPickerLayout(input: BuildLayoutInput): ModelPickerStat pool = allEntries.filter((entry) => entry.family === target); } + const providerTabs = (() => { + if (searchActive || input.selection.kind !== "provider") return []; + const groups = new Map(); + for (const entry of pool) { + const key = entry.subProviderKey || entry.subProvider || "__default__"; + const label = entry.subProvider || providerLabel(input.selection.provider); + const existing = groups.get(key); + if (existing) { + existing.entries.push(entry); + existing.hasAvailable = existing.hasAvailable || entry.isAvailable; + } else { + groups.set(key, { key, label, entries: [entry], hasAvailable: entry.isAvailable }); + } + } + return [...groups.values()]; + })(); + const activeProviderTabKey = (() => { + if (providerTabs.length <= 1) return null; + if (input.providerTabKey && providerTabs.some((tab) => tab.key === input.providerTabKey)) { + return input.providerTabKey; + } + const active = input.activeModelId ? allEntries.find((entry) => entry.modelId === input.activeModelId) : null; + if (active?.subProviderKey && providerTabs.some((tab) => tab.key === active.subProviderKey)) { + return active.subProviderKey; + } + return providerTabs.find((tab) => tab.hasAvailable)?.key ?? providerTabs[0]?.key ?? null; + })(); + if (providerTabs.length > 1 && activeProviderTabKey) { + pool = providerTabs.find((tab) => tab.key === activeProviderTabKey)?.entries ?? pool; + } + let entries: ModelPickerEntry[]; if (searchActive) { const scored: Array<{ entry: ModelPickerEntry; score: number }> = []; @@ -168,8 +244,10 @@ export function buildModelPickerLayout(input: BuildLayoutInput): ModelPickerStat searchMode: input.searchMode, railEntries, railIndex, - entries, - focusedIndex, + entries, + providerTabs: providerTabs.map((tab) => ({ key: tab.key, label: tab.label })), + providerTabIndex: Math.max(0, providerTabs.findIndex((tab) => tab.key === activeProviderTabKey)), + focusedIndex, activeModelId: input.activeModelId, }; } diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/types.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/types.ts index 71fe7fe37..cc83a45fd 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/types.ts +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/types.ts @@ -16,16 +16,24 @@ export type ModelPickerEntry = { family: AdeCodeProvider; /** Optional sub-provider label (e.g. "anthropic via OpenCode"). */ subProvider?: string; + subProviderKey?: string; isFavorite: boolean; isAvailable: boolean; }; +export type ModelPickerProviderTab = { + key: string; + label: string; +}; + export type ModelPickerState = { query: string; searchMode: boolean; railEntries: ModelPickerRailEntry[]; railIndex: number; entries: ModelPickerEntry[]; + providerTabs: ModelPickerProviderTab[]; + providerTabIndex: number; focusedIndex: number; activeModelId: string | null; }; diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index 8c3e6e247..e18292da1 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -12,7 +12,7 @@ import { theme } from "../theme"; import { buildSubagentPaneRows, type SubagentPaneRow } from "../subagentPane"; import { ModelPickerPane } from "./ModelPicker/ModelPickerPane"; import { buildModelPickerLayout } from "./ModelPicker/modelPickerLayout"; -import type { AgentChatModelInfo } from "../../../../desktop/src/shared/types/chat"; +import type { AgentChatModelCatalog, AgentChatModelInfo } from "../../../../desktop/src/shared/types/chat"; // --------------------------------------------------------------------------- // Right-pane width / focus chrome @@ -699,9 +699,10 @@ export function RightPane({ activeProvider?: AdeCodeProvider | null; width?: number; /** Data passed in by app.tsx for the model-picker content kind. */ - modelPickerInputs?: { - models: AgentChatModelInfo[]; - favorites: string[]; + modelPickerInputs?: { + models: AgentChatModelInfo[]; + catalog?: AgentChatModelCatalog | null; + favorites: string[]; recents: string[]; activeModelId: string | null; }; @@ -801,14 +802,16 @@ export function RightPane({ {content.kind === "model-picker" && modelPickerInputs ? ( = { cursor: { glyph: "▰", wordmark: "Cursor", color: CURSOR, label: "Cursor" }, droid: { glyph: "◉", wordmark: "Droid", color: DROID, label: "Droid" }, opencode: { glyph: "▲", wordmark: "OpenCode", color: OPENCODE, label: "OpenCode" }, + ollama: { glyph: "●", wordmark: "Ollama", color: OLLAMA, label: "Ollama" }, + lmstudio: { glyph: "◆", wordmark: "LM Studio", color: LMSTUDIO, label: "LM Studio" }, }; const FALLBACK_PROVIDER: ProviderTheme = { glyph: "•", wordmark: "Agent", color: T4, label: "Agent" }; diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index 154658622..7d63213f8 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -58,7 +58,7 @@ export type AdeCodeConnection = { close(): Promise; }; -export type AdeCodeProvider = Extract; +export type AdeCodeProvider = Extract | "ollama" | "lmstudio"; export type AdeCodeModelState = { provider: AdeCodeProvider; @@ -160,6 +160,7 @@ export type ModelPickerRightPaneContent = { query: string; searchMode: boolean; selection: ModelPickerRightPaneSelection; + providerTabKey?: string | null; focusedIndex: number; }; diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index f69ff0793..2752e8d89 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -198,9 +198,30 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { const chatActions = ADE_ACTION_ALLOWLIST.chat ?? []; expect(chatActions).toContain("ensureCtoSession"); expect(chatActions).toContain("ensureAgentIdentitySession"); + expect(chatActions).toContain("modelCatalog"); expect(ADE_ACTION_ALLOWLIST.cto_state ?? []).toContain("runProjectScan"); }); + it("exposes chat.modelCatalog as a runtime action alias", async () => { + const getModelCatalog = vi.fn(async (args?: unknown) => ({ args, groups: [] })); + const runtime = { + agentChatService: { + getModelCatalog, + }, + } as unknown as Parameters[0]; + + const chat = getAdeActionDomainServices(runtime).chat as { + modelCatalog?: (args?: unknown) => Promise; + }; + + await expect(chat.modelCatalog?.({ mode: "cached" })).resolves.toEqual({ + args: { mode: "cached" }, + groups: [], + }); + expect(listAllowedAdeActionNames("chat", chat as Record)).toContain("modelCatalog"); + expect(getModelCatalog).toHaveBeenCalledWith({ mode: "cached" }); + }); + it("exposes the browser panel and tab control surface", () => { const actions = ADE_ACTION_ALLOWLIST.built_in_browser ?? []; for (const name of ["showPanel", "navigate", "createTab", "switchTab", "closeTab"]) { diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index ac7afda67..b549cb507 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -434,6 +434,7 @@ export const ADE_ACTION_ALLOWLIST: Partial + agentChatService.getModelCatalog(args && typeof args === "object" ? args as never : undefined), setParallelLaunchState: (args?: AgentChatSetParallelLaunchStateArgs) => { const parentLaneId = requireNonEmptyString(args?.parentLaneId, "parentLaneId"); const key = agentChatParallelLaunchStateKey(runtime.projectRoot, parentLaneId); diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts index 23031a9d9..aa1a5454f 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts @@ -473,21 +473,18 @@ describe("aiIntegrationService", () => { expect(secondStatus).toEqual(firstStatus); }); - it("warms OpenCode inventory on first getStatus when the cache is empty", async () => { + it("skips OpenCode probe on cold getStatus — cold reads stay cheap", async () => { + // Cold reads only peek the cache. Runtime catalog refreshes are owned by + // agentChatService.getModelCatalog() and fire when a client opens a + // dynamic runtime rail, not on every status read. const { service } = makeService(); const status = await service.getStatus(); expect(status.opencodeBinaryInstalled).toBe(true); - expect(status.opencodeProviders).toEqual([ - { id: "openai", name: "OpenAI", connected: true, modelCount: 1 }, - ]); - expect(status.availableModelIds).toContain("opencode/openai/gpt-5.4-mini"); + expect(status.opencodeProviders).toEqual([]); expect(mockState.peekOpenCodeInventoryCache).toHaveBeenCalledTimes(1); - expect(mockState.probeOpenCodeProviderInventory).toHaveBeenCalledTimes(1); - expect(mockState.probeOpenCodeProviderInventory).toHaveBeenCalledWith( - expect.objectContaining({ force: false }), - ); + expect(mockState.probeOpenCodeProviderInventory).not.toHaveBeenCalled(); }); it("uses peeked OpenCode inventory without re-probing when cache is warm", async () => { diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 06d2dcfe0..88fa1f645 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -74,7 +74,7 @@ import { discoverDroidCliModelDescriptors, clearDroidCliModelsCache } from "../c import { resolveDroidExecutable } from "./droidExecutable"; import { buildProviderConnections } from "./providerConnectionStatus"; import { getProviderRuntimeHealthVersion, resetProviderRuntimeHealth } from "./providerRuntimeHealth"; -import { probeClaudeRuntimeHealth, resetClaudeRuntimeProbeCache } from "./claudeRuntimeProbe"; +import { resetClaudeRuntimeProbeCache } from "./claudeRuntimeProbe"; import { runProviderTask } from "./providerTaskRunner"; import { resolveClaudeCodeExecutable } from "./claudeCodeExecutable"; @@ -145,7 +145,7 @@ export type AiIntegrationStatus = { /** Last inventory probe error, if any (empty models when set after a failed probe). */ opencodeInventoryError?: string | null; /** All providers reported by OpenCode's provider.list() — used to dynamically populate the settings UI and model picker. */ - opencodeProviders?: Array<{ id: string; name: string; connected: boolean; modelCount: number }>; + opencodeProviders?: Array<{ id: string; name: string; connected: boolean; modelCount: number; availableModelCount?: number }>; apiKeyStore?: { secureStorageAvailable: boolean; macosKeychainAvailable?: boolean; @@ -1800,6 +1800,7 @@ export function createAiIntegrationService(args: { return { error: null as string | null, modelIds: [] as string[], + catalogModelIds: [] as string[], providers: [] as NonNullable, }; } @@ -1817,18 +1818,15 @@ export function createAiIntegrationService(args: { projectConfig: effectiveConfig, }); if (peeked) return peeked; - // Cold path: binary is installed but we have never probed for this - // project+config. Warm the cache so the model picker surfaces every - // connected OpenCode provider without requiring the user to enter - // an OpenCode chat first. Uses force=false so the in-flight - // dedup + TTL still apply, preventing repeated server boots. - return await probeOpenCodeProviderInventory({ - projectRoot, - projectConfig: effectiveConfig, - logger, - force: false, - discoveredLocalModels, - }); + // Cold status reads stay cheap. Runtime catalog refreshes are owned + // by agentChatService.getModelCatalog() and only run when a client + // opens a dynamic runtime rail. + return { + error: null as string | null, + modelIds: [] as string[], + catalogModelIds: [] as string[], + providers: [] as NonNullable, + }; }); // When OpenCode inventory has models for a local provider, remove the diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 82d9aaf6b..8b0dddc60 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -378,6 +378,7 @@ vi.mock("../opencode/openCodeInventory", () => ({ peekOpenCodeInventoryCache: vi.fn(() => null), probeOpenCodeProviderInventory: vi.fn(async () => ({ modelIds: ["opencode/openai/gpt-5.4"], + catalogModelIds: ["opencode/openai/gpt-5.4"], providers: [], error: null, descriptors: [], @@ -783,24 +784,24 @@ function bridgeClaudeSessionToQuery(sessionHandle: any, prompt: unknown) { } return undefined; }), - reloadPlugins: vi.fn(async () => { - if (typeof session.reloadPlugins === "function") { - return session.reloadPlugins(); - } - if (typeof session.query?.reloadPlugins === "function") { - return session.query.reloadPlugins(); - } - return { commands: [], agents: [], plugins: [], error_count: 0 }; - }), - applyFlagSettings: vi.fn(async (settings: unknown) => { - if (typeof session.applyFlagSettings === "function") { - return session.applyFlagSettings(settings); - } - if (typeof session.query?.applyFlagSettings === "function") { - return session.query.applyFlagSettings(settings); - } - return undefined; - }), + reloadPlugins: vi.fn(async () => { + if (typeof session.reloadPlugins === "function") { + return session.reloadPlugins(); + } + if (typeof session.query?.reloadPlugins === "function") { + return session.query.reloadPlugins(); + } + return { commands: [], agents: [], plugins: [], error_count: 0 }; + }), + applyFlagSettings: vi.fn(async (settings: unknown) => { + if (typeof session.applyFlagSettings === "function") { + return session.applyFlagSettings(settings); + } + if (typeof session.query?.applyFlagSettings === "function") { + return session.query.applyFlagSettings(settings); + } + return undefined; + }), setModel: vi.fn(async (model: string) => { if (typeof session.setModel === "function") { return session.setModel(model); @@ -1319,6 +1320,7 @@ beforeEach(() => { vi.mocked(probeOpenCodeProviderInventory).mockReset(); vi.mocked(probeOpenCodeProviderInventory).mockResolvedValue({ modelIds: ["opencode/openai/gpt-5.4"], + catalogModelIds: ["opencode/openai/gpt-5.4"], providers: [], error: null, descriptors: [], @@ -4232,35 +4234,35 @@ describe("createAgentChatService", () => { ])); }); - it("does not advertise /login as a Claude SDK command", async () => { - const { service } = createService(); - const session = await service.createSession({ - laneId: "lane-1", - provider: "claude", - model: "sonnet", - }); - - const commands = service.getSlashCommands({ sessionId: session.id }); - const loginCmd = commands.find((c: any) => c.name === "/login"); - expect(loginCmd).toBeUndefined(); - }); - - it("advertises the ADE-hosted Claude output-style command", async () => { - const { service } = createService(); - const session = await service.createSession({ - laneId: "lane-1", - provider: "claude", - model: "sonnet", - }); - - const commands = service.getSlashCommands({ sessionId: session.id }); - expect(commands).toEqual(expect.arrayContaining([ - expect.objectContaining({ - name: "/output-style", - source: "sdk", - }), - ])); - }); + it("does not advertise /login as a Claude SDK command", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + const commands = service.getSlashCommands({ sessionId: session.id }); + const loginCmd = commands.find((c: any) => c.name === "/login"); + expect(loginCmd).toBeUndefined(); + }); + + it("advertises the ADE-hosted Claude output-style command", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + const commands = service.getSlashCommands({ sessionId: session.id }); + expect(commands).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "/output-style", + source: "sdk", + }), + ])); + }); it("removes dead-listed Codex slash commands from the palette", async () => { const { service } = createService(); @@ -4445,57 +4447,57 @@ describe("createAgentChatService", () => { }), ])); }); - }); - - describe("Claude output styles", () => { - it("lists built-in and project-local output styles for a Claude session", async () => { - const stylesDir = path.join(tmpRoot, ".claude", "output-styles"); - fs.mkdirSync(stylesDir, { recursive: true }); - fs.writeFileSync(path.join(stylesDir, "reviewer.md"), [ - "---", - "name: Reviewer", - "description: Review first", - "---", - "", - "Review first.", - "", - ].join("\n")); - const { service } = createService(); - const session = await service.createSession({ - laneId: "lane-1", - provider: "claude", - model: "sonnet", - }); - - expect(service.listClaudeOutputStyles({ sessionId: session.id })).toEqual(expect.arrayContaining([ - expect.objectContaining({ name: "Default", source: "builtin" }), - expect.objectContaining({ name: "Reviewer", source: "project", description: "Review first" }), - ])); - }); - - it("persists and applies an output style to a live Claude query", async () => { - const applyFlagSettings = vi.fn(async () => undefined); - vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ - ...makeDefaultClaudeSession(), - applyFlagSettings, - }); - const { service } = createService(); - const session = await service.createSession({ - laneId: "lane-1", - provider: "claude", - model: "sonnet", - }); - - await service.sendMessage({ sessionId: session.id, text: "hello" }); - const updated = await service.setClaudeOutputStyle({ sessionId: session.id, outputStyle: "Learning" }); - - expect(updated.claudeOutputStyle).toBe("Learning"); - expect(applyFlagSettings).toHaveBeenCalledWith({ outputStyle: "Learning" }); - expect(JSON.parse(fs.readFileSync(path.join(tmpRoot, ".claude", "settings.local.json"), "utf8"))).toMatchObject({ - outputStyle: "Learning", - }); - }); - }); + }); + + describe("Claude output styles", () => { + it("lists built-in and project-local output styles for a Claude session", async () => { + const stylesDir = path.join(tmpRoot, ".claude", "output-styles"); + fs.mkdirSync(stylesDir, { recursive: true }); + fs.writeFileSync(path.join(stylesDir, "reviewer.md"), [ + "---", + "name: Reviewer", + "description: Review first", + "---", + "", + "Review first.", + "", + ].join("\n")); + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + expect(service.listClaudeOutputStyles({ sessionId: session.id })).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "Default", source: "builtin" }), + expect.objectContaining({ name: "Reviewer", source: "project", description: "Review first" }), + ])); + }); + + it("persists and applies an output style to a live Claude query", async () => { + const applyFlagSettings = vi.fn(async () => undefined); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + ...makeDefaultClaudeSession(), + applyFlagSettings, + }); + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await service.sendMessage({ sessionId: session.id, text: "hello" }); + const updated = await service.setClaudeOutputStyle({ sessionId: session.id, outputStyle: "Learning" }); + + expect(updated.claudeOutputStyle).toBe("Learning"); + expect(applyFlagSettings).toHaveBeenCalledWith({ outputStyle: "Learning" }); + expect(JSON.parse(fs.readFileSync(path.join(tmpRoot, ".claude", "settings.local.json"), "utf8"))).toMatchObject({ + outputStyle: "Learning", + }); + }); + }); describe("Claude context usage", () => { it("normalizes used and free context categories against the full context window", async () => { @@ -4537,67 +4539,67 @@ describe("createAgentChatService", () => { }); describe("Claude plugins", () => { - it("lists discovered local Claude plugins", async () => { - const pluginRoot = path.join(tmpRoot, ".claude", "plugins", "team-tools", "review-plugin"); - fs.mkdirSync(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); - fs.writeFileSync(path.join(pluginRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ - name: "review-plugin", - description: "Review helpers", - })); - fs.writeFileSync(path.join(tmpRoot, ".claude", "settings.json"), JSON.stringify({ - enabledPlugins: { "review-plugin@local": true }, - })); - const { service } = createService(); - const session = await service.createSession({ - laneId: "lane-1", - provider: "claude", - model: "sonnet", - }); - - expect(service.listClaudePlugins({ sessionId: session.id })).toEqual([ - expect.objectContaining({ - name: "review-plugin", - description: "Review helpers", - path: fs.realpathSync(pluginRoot), - }), - ]); - }); - - it("reloads plugins through the live Claude query", async () => { - const reloadPlugins = vi.fn(async () => ({ - plugins: [{ name: "review-plugin", path: "/tmp/review-plugin" }], - commands: [{ name: "review-plugin:audit", description: "Audit" }], - agents: [{ name: "reviewer", description: "Review code" }], - error_count: 0, - })); - vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ - ...makeDefaultClaudeSession(), - reloadPlugins, - }); - const { service } = createService(); - const session = await service.createSession({ - laneId: "lane-1", - provider: "claude", - model: "sonnet", - }); - - await service.sendMessage({ sessionId: session.id, text: "hello" }); - const result = await service.reloadClaudePlugins({ sessionId: session.id }); - - expect(reloadPlugins).toHaveBeenCalled(); + it("lists discovered local Claude plugins", async () => { + const pluginRoot = path.join(tmpRoot, ".claude", "plugins", "team-tools", "review-plugin"); + fs.mkdirSync(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); + fs.writeFileSync(path.join(pluginRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ + name: "review-plugin", + description: "Review helpers", + })); + fs.writeFileSync(path.join(tmpRoot, ".claude", "settings.json"), JSON.stringify({ + enabledPlugins: { "review-plugin@local": true }, + })); + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + expect(service.listClaudePlugins({ sessionId: session.id })).toEqual([ + expect.objectContaining({ + name: "review-plugin", + description: "Review helpers", + path: fs.realpathSync(pluginRoot), + }), + ]); + }); + + it("reloads plugins through the live Claude query", async () => { + const reloadPlugins = vi.fn(async () => ({ + plugins: [{ name: "review-plugin", path: "/tmp/review-plugin" }], + commands: [{ name: "review-plugin:audit", description: "Audit" }], + agents: [{ name: "reviewer", description: "Review code" }], + error_count: 0, + })); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + ...makeDefaultClaudeSession(), + reloadPlugins, + }); + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await service.sendMessage({ sessionId: session.id, text: "hello" }); + const result = await service.reloadClaudePlugins({ sessionId: session.id }); + + expect(reloadPlugins).toHaveBeenCalled(); expect(result).toEqual(expect.objectContaining({ - plugins: [expect.objectContaining({ name: "review-plugin", path: "/tmp/review-plugin" })], - commands: [expect.objectContaining({ name: "review-plugin:audit", description: "Audit" })], - agents: [expect.objectContaining({ name: "reviewer", description: "Review code" })], - errorCount: 0, - })); + plugins: [expect.objectContaining({ name: "review-plugin", path: "/tmp/review-plugin" })], + commands: [expect.objectContaining({ name: "review-plugin:audit", description: "Audit" })], + agents: [expect.objectContaining({ name: "reviewer", description: "Review code" })], + errorCount: 0, + })); expect(service.getSlashCommands({ sessionId: session.id })).toEqual(expect.arrayContaining([ expect.objectContaining({ name: "/review-plugin:audit", description: "Audit" }), ])); }); }); - it("sends Claude provider slash commands as the raw SDK prompt", async () => { + it("sends Claude provider slash commands as the raw SDK prompt", async () => { const send = vi.fn().mockResolvedValue(undefined); let streamCall = 0; const stream = vi.fn(() => (async function* () { @@ -8295,12 +8297,21 @@ describe("createAgentChatService", () => { // -------------------------------------------------------------------------- describe("getAvailableModels", () => { - it("warms OpenCode models on a passive cache miss", async () => { + it("keeps OpenCode model discovery passive on a cache miss", async () => { clearOpenCodeInventoryCache(); const { service } = createService(); const models = await service.getAvailableModels({ provider: "opencode" }); expect(peekOpenCodeInventoryCache).toHaveBeenCalled(); + expect(probeOpenCodeProviderInventory).not.toHaveBeenCalled(); + expect(models).toEqual([]); + }); + + it("refreshes OpenCode models only when runtime activation is requested", async () => { + clearOpenCodeInventoryCache(); + const { service } = createService(); + const models = await service.getAvailableModels({ provider: "opencode", activateRuntime: true }); + expect(probeOpenCodeProviderInventory).toHaveBeenCalled(); expect(models.map((model) => model.id)).toContain("opencode/openai/gpt-5.4"); }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 680890e85..bf7213668 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -75,6 +75,7 @@ import { nowIso, readFileWithinRootSecure, resolvePathWithinRoot, + stableStringify, } from "../shared/utils"; import { resolveCliSpawnInvocation, @@ -121,6 +122,8 @@ import type { AgentChatInteractionMode, AgentChatInterruptArgs, AgentChatModelCatalog, + AgentChatModelCatalogArgs, + AgentChatModelCatalogRefreshProvider, AgentChatModelInfo, AgentChatProvider, AgentChatRespondToInputArgs, @@ -263,7 +266,10 @@ import { releaseDroidSdkConnection, type DroidSdkPooled, } from "./droidSdkPool"; -import { discoverCursorSdkModelDescriptors } from "./cursorModelsDiscovery"; +import { + discoverCursorSdkModelDescriptors, + resolveCursorSdkModelSelectionParams, +} from "./cursorModelsDiscovery"; import { discoverDroidSdkModelDescriptors } from "./droidModelsDiscovery"; import { mapCursorSdkMessageToChatEvents, @@ -1855,7 +1861,7 @@ function normalizeCodexFastMode(value: unknown): boolean { } function catalogDescriptorInfoKey( - group: ModelProviderGroup, + group: string, providerKey: string, descriptorId: string, ): string { @@ -2123,6 +2129,18 @@ function validateReasoningEffortForDescriptor( return validated; } +function validateRuntimeReasoningEffortForDescriptor( + effort: string | null | undefined, + descriptor?: ModelDescriptor | null, +): string | null { + const normalized = normalizeReasoningEffort(effort); + if (!normalized) return null; + if (descriptor?.reasoningTiers?.length && !descriptor.reasoningTiers.includes(normalized)) { + return null; + } + return normalized; +} + function resolveCodexReasoningEffortForRuntime( primary: string | null | undefined, fallback?: string | null, @@ -3995,6 +4013,7 @@ function normalizeDroidSdkReasoningEffort(value: string | null | undefined): Dro case "medium": case "high": case "xhigh": + case "max": return normalized; case "extra-high": case "extra_high": @@ -10856,19 +10875,30 @@ export function createAgentChatService(args: { })) .filter((entry) => fs.existsSync(entry.path)); const toolSelection = await refreshOpenCodeSessionToolSelection(runtime.handle); + const openCodeReasoningVariant = + managed.session.reasoningEffort + && runtime.modelDescriptor.reasoningTiers?.includes(managed.session.reasoningEffort) + ? managed.session.reasoningEffort + : null; + const openCodeVariant = + managed.session.codexFastMode === true && modelSupportsFastMode(runtime.modelDescriptor) + ? "fast" + : openCodeReasoningVariant; + const openCodePromptBody = { + agent: mapPermissionModeToOpenCodeAgent(runtime.permissionMode), + model: resolveOpenCodeModelSelection(runtime.modelDescriptor), + ...(toolSelection ? { tools: toolSelection } : {}), + ...(openCodeVariant ? { variant: openCodeVariant } : {}), + parts: buildOpenCodePromptParts({ + prompt: userContent, + files: toPromptFiles, + }), + }; const promptAccepted = runtime.handle.client.session.promptAsync({ path: { id: runtime.handle.sessionId }, query: { directory: runtime.handle.directory }, - body: { - agent: mapPermissionModeToOpenCodeAgent(runtime.permissionMode), - model: resolveOpenCodeModelSelection(runtime.modelDescriptor), - ...(toolSelection ? { tools: toolSelection } : {}), - parts: buildOpenCodePromptParts({ - prompt: userContent, - files: toPromptFiles, - }), - }, + body: openCodePromptBody, }); const eventStream = await openCodeEventStream({ @@ -14895,11 +14925,9 @@ export function createAgentChatService(args: { const rawEffort = effectiveProvider === "codex" ? normalizeReasoningEffort(reasoningEffort) ?? DEFAULT_REASONING_EFFORT : normalizeReasoningEffort(reasoningEffort); - const normalizedReasoningEffort = effectiveProvider === "opencode" - ? rawEffort - : effectiveProvider === "cursor" || effectiveProvider === "droid" - ? null - : validateReasoningEffortForDescriptor( + const normalizedReasoningEffort = effectiveProvider === "opencode" || effectiveProvider === "cursor" || effectiveProvider === "droid" + ? validateRuntimeReasoningEffortForDescriptor(rawEffort, resolvedDescriptor) + : validateReasoningEffortForDescriptor( effectiveProvider === "claude" ? "claude" : "codex", rawEffort, resolvedDescriptor, @@ -15030,7 +15058,7 @@ export function createAgentChatService(args: { ...(resolvedModelId ? { modelId: resolvedModelId } : {}), sessionProfile: sessionProfile ?? "workflow", ...(normalizedReasoningEffort ? { reasoningEffort: normalizedReasoningEffort } : {}), - ...(effectiveProvider === "codex" && requestedCodexFastMode === true ? { codexFastMode: true } : {}), + ...(requestedCodexFastMode === true ? { codexFastMode: true } : {}), ...nativePermissionFields, ...(initialClaudeOutputStyle ? { claudeOutputStyle: initialClaudeOutputStyle } : {}), ...(effectivePermissionMode ? { permissionMode: effectivePermissionMode } : {}), @@ -15197,7 +15225,7 @@ export function createAgentChatService(args: { modelId: targetDescriptor.id, sessionProfile: managed.session.sessionProfile, reasoningEffort: targetReasoningEffort, - codexFastMode: targetProvider === "codex" + codexFastMode: modelSupportsFastMode(targetDescriptor) ? args.codexFastMode ?? managed.session.codexFastMode === true : undefined, claudePermissionMode: args.claudePermissionMode ?? managed.session.claudePermissionMode, @@ -15548,17 +15576,29 @@ export function createAgentChatService(args: { managed: ManagedChatSession, policy: CursorSdkPermissionPolicy, modelSdkId: string, + modelParams?: Array<{ id: string; value: string }>, ): string => [ "sdk", managed.session.id, managed.session.laneId, managed.laneWorktreePath, modelSdkId, + stableStringify((modelParams ?? []).map((entry) => [entry.id, entry.value])), policy.chatMode, policy.approvalPolicy, policy.force ? "force" : "guarded", ].join(":"); + const resolveCursorSdkModelParamsForSession = ( + session: Pick, + modelSdkId: string, + ): Array<{ id: string; value: string }> | undefined => + resolveCursorSdkModelSelectionParams({ + modelSdkId, + reasoningEffort: session.reasoningEffort, + fastMode: session.codexFastMode === true, + }); + const normalizeCursorSdkToolName = (name: string): string => name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_"); @@ -15665,21 +15705,6 @@ export function createAgentChatService(args: { return `System context: Cursor Agent mode is active. Use ADE approval outcomes from approval messages. ${cursorSdkAdeControlDirective()}`; }; - const cursorPermissionOptionLabel = (kind: PermissionOption["kind"]): string => { - switch (kind) { - case "allow_once": - return "Allow once"; - case "allow_always": - return "Allow for session"; - case "reject_once": - return "Reject once"; - case "reject_always": - return "Reject for session"; - default: - return kind; - } - }; - const buildCursorSdkPendingInputRequest = ( itemId: string, req: CursorSdkHookRequest, @@ -16571,7 +16596,8 @@ export function createAgentChatService(args: { const policy = resolveCursorSdkPolicy(managed.session); const displayModeId = resolveCursorDisplayModeId(managed.session, policy); const launchModelSdkId = resolveCursorRuntimeModelSdkId(managed.session); - const poolKey = cursorSdkPoolKeyFor(managed, policy, launchModelSdkId); + const launchModelParams = resolveCursorSdkModelParamsForSession(managed.session, launchModelSdkId); + const poolKey = cursorSdkPoolKeyFor(managed, policy, launchModelSdkId, launchModelParams); const shouldSyncSessionModel = managed.session.model !== launchModelSdkId || !managed.session.modelId; if (shouldSyncSessionModel) { syncCursorSessionDescriptor(managed, launchModelSdkId); @@ -16618,6 +16644,7 @@ export function createAgentChatService(args: { projectRoot, workspacePath: managed.laneWorktreePath, modelSdkId: launchModelSdkId, + ...(launchModelParams?.length ? { modelParams: launchModelParams } : {}), apiKey, agentId: persistedCursorSdkAgentId, agentName: manualSessionTitleForRuntime(managed), @@ -16798,6 +16825,7 @@ export function createAgentChatService(args: { promptText, images, modelSdkId: runtime.modelSdkId, + modelParams: resolveCursorSdkModelParamsForSession(managed.session, runtime.modelSdkId), force: policy.force, }); @@ -17130,11 +17158,15 @@ export function createAgentChatService(args: { try { let result: unknown; if (isFollowUp && managed.session.cursorCloudAgentId) { + const modelParams = runtime.modelSdkId + ? resolveCursorSdkModelParamsForSession(managed.session, runtime.modelSdkId) + : undefined; const payload: CursorSdkCloudFollowupPayload = { apiKey, agentId: managed.session.cursorCloudAgentId, promptText, ...(runtime.modelSdkId ? { modelSdkId: runtime.modelSdkId } : {}), + ...(modelParams?.length ? { modelParams } : {}), }; result = await runtime.sdk.request( "cloud.followup", @@ -17143,12 +17175,16 @@ export function createAgentChatService(args: { } else { const repoUrl = await resolveCloudRepoUrl(managed, args.cloudOverrides); const manualAgentName = manualSessionTitleForRuntime(managed); + const modelParams = runtime.modelSdkId + ? resolveCursorSdkModelParamsForSession(managed.session, runtime.modelSdkId) + : undefined; const payload: CursorSdkCloudSendStreamPayload = { apiKey, promptText, repoUrl, ...(manualAgentName ? { agentName: manualAgentName } : {}), ...(runtime.modelSdkId ? { modelSdkId: runtime.modelSdkId } : {}), + ...(modelParams?.length ? { modelParams } : {}), ...(args.cloudOverrides?.startingRef ? { startingRef: args.cloudOverrides.startingRef } : {}), ...(args.cloudOverrides?.prUrl !== undefined ? { prUrl: args.cloudOverrides.prUrl } : {}), ...(args.cloudOverrides?.workOnCurrentBranch !== undefined @@ -19775,6 +19811,80 @@ export function createAgentChatService(args: { }; const availableModelsRequests = new Map>(); + const modelCatalogRequests = new Map>(); + const modelCatalogProviderRefreshedAt = new Map(); + const MODEL_CATALOG_REFRESH_TTL_MS = 30 * 60_000; + const MODEL_CATALOG_LOCAL_REFRESH_TTL_MS = 30_000; + const MODEL_CATALOG_REFRESH_PROVIDERS: AgentChatModelCatalogRefreshProvider[] = [ + "opencode", + "cursor", + "droid", + "lmstudio", + "ollama", + ]; + let modelCatalogCache: AgentChatModelCatalog | null = null; + + const modelCatalogRefreshTtlMs = (provider?: AgentChatModelCatalogRefreshProvider): number => + provider === "lmstudio" || provider === "ollama" + ? MODEL_CATALOG_LOCAL_REFRESH_TTL_MS + : MODEL_CATALOG_REFRESH_TTL_MS; + + const markModelCatalogProviderFresh = ( + refreshProvider: AgentChatModelCatalogRefreshProvider | undefined, + refreshedAt: number, + ): void => { + if (refreshProvider) { + modelCatalogProviderRefreshedAt.set(refreshProvider, refreshedAt); + return; + } + for (const provider of MODEL_CATALOG_REFRESH_PROVIDERS) { + modelCatalogProviderRefreshedAt.set(provider, refreshedAt); + } + }; + + const isModelCatalogRefreshStale = (refreshProvider?: AgentChatModelCatalogRefreshProvider): boolean => { + if (!modelCatalogCache) return true; + if (refreshProvider) { + const refreshedAt = modelCatalogProviderRefreshedAt.get(refreshProvider); + return !refreshedAt || Date.now() - refreshedAt > modelCatalogRefreshTtlMs(refreshProvider); + } + const fetchedAt = Date.parse(modelCatalogCache.fetchedAt); + return !Number.isFinite(fetchedAt) || Date.now() - fetchedAt > MODEL_CATALOG_REFRESH_TTL_MS; + }; + + const withModelCatalogStaleFlag = ( + catalog: AgentChatModelCatalog, + stale: boolean, + ): AgentChatModelCatalog => ({ + ...catalog, + stale, + }); + + const discoverOpenCodeLocalModels = async (): Promise => { + const auth = await detectAuth(); + const snapshot = projectConfigService.get(); + const localProviderConfigs = snapshot.effective.ai?.localProviders ?? {}; + const discoveredLocalModels: DiscoveredLocalModelEntry[] = []; + for (const family of ["ollama", "lmstudio"] as const) { + const providerSettings = localProviderConfigs[family]; + if (providerSettings?.enabled === false) continue; + const localAuth = auth.find( + (a): a is Extract => + a.type === "local" && a.provider === family, + ); + const endpoint = localAuth?.endpoint ?? providerSettings?.endpoint ?? getLocalProviderDefaultEndpoint(family); + try { + const inspection = await inspectLocalProvider(family, endpoint); + for (const m of inspection.loadedModels) { + discoveredLocalModels.push({ provider: m.provider, modelId: m.modelId }); + } + } catch { + // Local runtime may be offline. The picker should show no local rows + // rather than falling back to static OpenCode provider data. + } + } + return discoveredLocalModels; + }; const loadAvailableModels = async (args: { provider: AgentChatProvider; @@ -19793,7 +19903,7 @@ export function createAgentChatService(args: { if (!apiKey) return []; try { const ordered = await discoverCursorSdkModelDescriptors(apiKey, { - mode: args.activateRuntime ? "probe" : "cached-or-fallback", + mode: args.activateRuntime ? "probe" : "cached-only", }); const preferred = pickDefaultCursorDescriptorFromCliList(ordered); return ordered.map((d) => ({ @@ -19809,6 +19919,7 @@ export function createAgentChatService(args: { family: d.family, supportsReasoning: d.capabilities.reasoning, supportsTools: d.capabilities.tools, + ...(d.serviceTiers?.length ? { serviceTiers: d.serviceTiers } : {}), color: d.color, })); } catch { @@ -19820,7 +19931,9 @@ export function createAgentChatService(args: { try { const auth = await detectAuth(); const droidPath = resolveDroidExecutable({ auth }).path; - const ordered = await discoverDroidSdkModelDescriptors(droidPath); + const ordered = await discoverDroidSdkModelDescriptors(droidPath, { + mode: args.activateRuntime ? "probe" : "cached-or-fallback", + }); const preferred = pickDefaultDroidDescriptorFromCliList(ordered); return ordered.map((d) => ({ id: d.id, @@ -19835,6 +19948,7 @@ export function createAgentChatService(args: { family: d.family, supportsReasoning: d.capabilities.reasoning, supportsTools: d.capabilities.tools, + ...(d.serviceTiers?.length ? { serviceTiers: d.serviceTiers } : {}), color: d.color, })); } catch { @@ -19848,11 +19962,13 @@ export function createAgentChatService(args: { let modelIds: string[]; let error: string | null; if (args.activateRuntime) { + const discoveredLocalModels = await discoverOpenCodeLocalModels(); const inventory = await probeOpenCodeProviderInventory({ projectRoot, projectConfig: effectiveConfig, logger, force: false, + discoveredLocalModels, }); modelIds = inventory.modelIds; error = inventory.error; @@ -19865,14 +19981,8 @@ export function createAgentChatService(args: { modelIds = peeked.modelIds; error = peeked.error; } else { - const inventory = await probeOpenCodeProviderInventory({ - projectRoot, - projectConfig: effectiveConfig, - logger, - force: false, - }); - modelIds = inventory.modelIds; - error = inventory.error; + modelIds = []; + error = null; } } if (error) { @@ -19978,14 +20088,39 @@ export function createAgentChatService(args: { } }; - const getModelCatalog = async (): Promise => { - const catalogProviders: ModelProviderGroup[] = ["claude", "codex", "cursor", "droid", "opencode"]; + const modelCatalogRequestKey = (catalogArgs?: AgentChatModelCatalogArgs): string => { + const mode = catalogArgs?.mode ?? "refresh-stale"; + const refreshProvider = catalogArgs?.refreshProvider; + return `${mode}:${refreshProvider ?? "all"}`; + }; + + const buildModelCatalog = async (catalogArgs?: AgentChatModelCatalogArgs): Promise => { + const mode = catalogArgs?.mode ?? "refresh-stale"; + const refreshProvider = catalogArgs?.refreshProvider; + if (mode === "cached" && modelCatalogCache) { + return withModelCatalogStaleFlag(modelCatalogCache, isModelCatalogRefreshStale()); + } + + const activateAllDynamic = mode === "force" && !refreshProvider; + const shouldRefreshProvider = (provider: AgentChatModelCatalogRefreshProvider): boolean => + mode !== "cached" && (activateAllDynamic || refreshProvider === provider); + const shouldRefreshOpenCode = + shouldRefreshProvider("opencode") + || shouldRefreshProvider("lmstudio") + || shouldRefreshProvider("ollama"); + + const catalogProviders: ModelProviderGroup[] = ["claude", "codex", "cursor", "droid"]; const modelsByProvider = await Promise.all( catalogProviders.map(async (provider) => { try { return { provider, - models: await getAvailableModels({ provider, activateRuntime: provider === "cursor" }), + models: await getAvailableModels({ + provider, + activateRuntime: + (provider === "cursor" && shouldRefreshProvider("cursor")) + || (provider === "droid" && shouldRefreshProvider("droid")), + }), }; } catch { return { provider, models: [] }; @@ -19993,6 +20128,29 @@ export function createAgentChatService(args: { }), ); + const effectiveConfig = projectConfigService.get().effective; + const opencodeInventory = await (async () => { + if (shouldRefreshOpenCode) { + return await probeOpenCodeProviderInventory({ + projectRoot, + projectConfig: effectiveConfig, + logger, + force: mode === "force", + discoveredLocalModels: await discoverOpenCodeLocalModels(), + }); + } + const peeked = peekOpenCodeInventoryCache({ + projectRoot, + projectConfig: effectiveConfig, + }); + return peeked ?? { + modelIds: [] as string[], + catalogModelIds: [] as string[], + providers: [], + error: null as string | null, + }; + })(); + const descriptorInfo = new Map(); const descriptors: ModelDescriptor[] = []; for (const { provider, models } of modelsByProvider) { @@ -20025,16 +20183,45 @@ export function createAgentChatService(args: { } } - const opencodeInventory = peekOpenCodeInventoryCache({ - projectRoot, - projectConfig: projectConfigService.get().effective, - }); - const blocks = buildProviderGroupBlocks(descriptors, createModelOrderMap(), opencodeInventory?.providers); + const availableOpenCodeIds = new Set(opencodeInventory.modelIds); + for (const id of opencodeInventory.catalogModelIds) { + const descriptor = getModelById(id); + if (!descriptor) continue; + descriptors.push(descriptor); + if (!availableOpenCodeIds.has(id)) continue; + const groupKey = + descriptor.providerRoute === "opencode" && (descriptor.family === "ollama" || descriptor.family === "lmstudio") + ? descriptor.family + : "opencode"; + const providerKey = groupKey === "opencode" && descriptor.openCodeProviderId + ? descriptor.openCodeProviderId + : descriptor.family; + const info: AgentChatModelInfo = { + id: descriptor.id, + displayName: descriptor.displayName, + description: `${descriptor.displayName} (OpenCode)`, + isDefault: false, + reasoningEfforts: descriptor.reasoningTiers?.map((tier) => ({ + effort: tier, + description: `${tier} reasoning`, + })) ?? [], + modelId: descriptor.id, + family: descriptor.family, + supportsReasoning: descriptor.capabilities.reasoning, + supportsTools: descriptor.capabilities.tools, + ...(descriptor.serviceTiers?.length ? { serviceTiers: descriptor.serviceTiers } : {}), + color: descriptor.color, + }; + descriptorInfo.set(catalogDescriptorInfoKey(groupKey, providerKey, descriptor.id), { provider: "opencode", info }); + } - return { + const opencodeProviderById = new Map(opencodeInventory.providers.map((provider) => [provider.id, provider])); + const blocks = buildProviderGroupBlocks(descriptors, createModelOrderMap(), opencodeInventory.providers); + + const catalog: AgentChatModelCatalog = { fetchedAt: nowIso(), groups: blocks.map((group) => ({ - key: group.key, + key: group.key as AgentChatProvider, displayName: group.label, providers: group.providers.map((provider) => ({ key: provider.key, @@ -20048,6 +20235,11 @@ export function createAgentChatService(args: { const entry = descriptorInfo.get(catalogDescriptorInfoKey(group.key, provider.key, descriptor.id)); const runtimeProvider = entry?.provider ?? resolveProviderGroupForModel(descriptor); const runtimeModelId = entry?.info.id ?? getRuntimeModelRefForDescriptor(descriptor, runtimeProvider); + const providerMeta = descriptor.openCodeProviderId + ? opencodeProviderById.get(descriptor.openCodeProviderId) + : group.key === "opencode" || group.key === "ollama" || group.key === "lmstudio" + ? opencodeProviderById.get(provider.key) + : undefined; const reasoningEfforts = entry?.info.reasoningEfforts ?? descriptor.reasoningTiers?.map((tier) => ({ effort: tier, @@ -20075,12 +20267,59 @@ export function createAgentChatService(args: { : {}), color: descriptor.color, isAvailable: Boolean(entry), + connected: providerMeta?.connected ?? Boolean(entry), + requiresConfiguration: !entry && (group.key === "opencode" || group.key === "ollama" || group.key === "lmstudio"), + sourceRuntime: runtimeProvider, + providerId: providerMeta?.id ?? provider.key, + providerName: providerMeta?.name ?? provider.label, }; }), })), })), })), }; + modelCatalogCache = catalog; + if (mode !== "cached") { + markModelCatalogProviderFresh(refreshProvider, Date.now()); + } + return catalog; + }; + + const loadModelCatalogRequest = (catalogArgs?: AgentChatModelCatalogArgs): Promise => { + const key = modelCatalogRequestKey(catalogArgs); + const existing = modelCatalogRequests.get(key); + if (existing) return existing; + + const request = buildModelCatalog(catalogArgs).finally(() => { + if (modelCatalogRequests.get(key) === request) { + modelCatalogRequests.delete(key); + } + }); + modelCatalogRequests.set(key, request); + return request; + }; + + const scheduleModelCatalogRefresh = (catalogArgs?: AgentChatModelCatalogArgs): void => { + void loadModelCatalogRequest(catalogArgs).catch((error) => { + logger.debug("agent_chat.model_catalog_background_refresh_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + }; + + const getModelCatalog = async (catalogArgs?: AgentChatModelCatalogArgs): Promise => { + const mode = catalogArgs?.mode ?? "refresh-stale"; + if (mode === "refresh-stale" && modelCatalogCache) { + const stale = isModelCatalogRefreshStale(catalogArgs?.refreshProvider); + if (stale) { + scheduleModelCatalogRefresh({ + mode: "force", + ...(catalogArgs?.refreshProvider ? { refreshProvider: catalogArgs.refreshProvider } : {}), + }); + } + return withModelCatalogStaleFlag(modelCatalogCache, stale); + } + return await loadModelCatalogRequest(catalogArgs); }; const dispose = async ({ sessionId }: AgentChatDisposeArgs): Promise => { @@ -20394,7 +20633,7 @@ export function createAgentChatService(args: { managed.session.provider = nextProvider; managed.session.modelId = descriptor.id; managed.session.model = nextModel; - if (nextProvider !== "codex") { + if (nextProvider === "claude") { delete managed.session.codexFastMode; } managed.session.capabilityMode = inferCapabilityMode(nextProvider); @@ -20431,9 +20670,7 @@ export function createAgentChatService(args: { ? validateReasoningEffortForDescriptor("codex", requested, descriptor) : nextProvider === "claude" ? validateReasoningEffortForDescriptor("claude", requested, descriptor) - : nextProvider === "opencode" - ? requested - : null; + : validateRuntimeReasoningEffortForDescriptor(requested, descriptor); } // Pre-warm the Claude query when the user selects an Anthropic model. @@ -20462,9 +20699,7 @@ export function createAgentChatService(args: { ? validateReasoningEffortForDescriptor("codex", requested, descriptor) : managed.session.provider === "claude" ? validateReasoningEffortForDescriptor("claude", requested, descriptor) - : managed.session.provider === "opencode" - ? requested - : null; + : validateRuntimeReasoningEffortForDescriptor(requested, descriptor); const next = managed.session.reasoningEffort ?? null; // When reasoning effort changes on a Claude session with an active query, // invalidate the query so it is recreated on the next turn @@ -20517,7 +20752,7 @@ export function createAgentChatService(args: { } if (codexFastMode !== undefined) { - if (managed.session.provider === "codex" && normalizeCodexFastMode(codexFastMode)) { + if (normalizeCodexFastMode(codexFastMode)) { managed.session.codexFastMode = true; } else { delete managed.session.codexFastMode; diff --git a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts index f161ad109..9c9c6258e 100644 --- a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts +++ b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.test.ts @@ -23,6 +23,7 @@ import { listCursorModelsFromSdk, parseCursorCliModelsStdout, probeCursorSdkModelDiscovery, + resolveCursorSdkModelSelectionParams, } from "./cursorModelsDiscovery"; beforeEach(() => { @@ -60,15 +61,15 @@ describe("parseCursorCliModelsStdout", () => { expect(rows).toHaveLength(1); }); - it("returns safe Cursor SDK fallbacks immediately while warming exact models", async () => { + it("warms exact Cursor SDK models only in cached-or-fallback mode", async () => { let resolveModels!: (rows: Array<{ id: string; displayName?: string }>) => void; cursorModelsListMock.mockReturnValue(new Promise>((resolve) => { resolveModels = resolve; })); - const initial = await discoverCursorSdkModelDescriptors("crsr_test"); + const initial = await discoverCursorSdkModelDescriptors("crsr_test", { mode: "cached-or-fallback" }); - expect(initial.map((descriptor) => descriptor.id)).toEqual(["cursor/auto", "cursor/composer-2"]); + expect(initial).toEqual([]); await vi.waitFor(() => { expect(cursorModelsListMock).toHaveBeenCalledWith({ apiKey: "crsr_test" }); }); @@ -87,7 +88,7 @@ describe("parseCursorCliModelsStdout", () => { expect(cursorModelsListMock).toHaveBeenCalledTimes(1); }); - it("can warm exact Cursor SDK models without returning fallback rows", async () => { + it("does not probe Cursor SDK models in cached-only mode", async () => { let resolveModels!: (rows: Array<{ id: string; displayName?: string }>) => void; cursorModelsListMock.mockReturnValue(new Promise>((resolve) => { resolveModels = resolve; @@ -96,10 +97,12 @@ describe("parseCursorCliModelsStdout", () => { const initial = await discoverCursorSdkModelDescriptors("crsr_test", { mode: "cached-only" }); expect(initial).toEqual([]); + expect(cursorModelsListMock).not.toHaveBeenCalled(); + + void discoverCursorSdkModelDescriptors("crsr_test", { mode: "cached-or-fallback" }); await vi.waitFor(() => { expect(cursorModelsListMock).toHaveBeenCalledWith({ apiKey: "crsr_test" }); }); - resolveModels([ { id: "claude-4.6-sonnet-medium", displayName: "Sonnet 4.6 Medium" }, { id: "auto", displayName: "Auto" }, @@ -131,6 +134,55 @@ describe("parseCursorCliModelsStdout", () => { expect(reportProviderRuntimeReadyMock).toHaveBeenCalledWith("cursor"); }); + it("preserves Cursor SDK parameters and variants as runtime reasoning and service tiers", async () => { + cursorModelsListMock.mockResolvedValue([ + { + id: "composer-2", + displayName: "Composer 2", + parameters: [ + { + id: "reasoning_effort", + displayName: "Reasoning effort", + values: [ + { value: "low", displayName: "Low" }, + { value: "high", displayName: "High" }, + ], + }, + { + id: "speed", + displayName: "Speed", + values: [{ value: "fast", displayName: "Fast" }], + }, + ], + variants: [ + { + displayName: "Fast High", + params: [ + { id: "reasoning_effort", value: "high" }, + { id: "speed", value: "fast" }, + ], + }, + ], + }, + ]); + + const descriptors = await discoverCursorSdkModelDescriptors("crsr_test", { mode: "probe" }); + + expect(descriptors[0]).toMatchObject({ + id: "cursor/composer-2", + reasoningTiers: ["low", "high"], + serviceTiers: ["fast"], + }); + expect(resolveCursorSdkModelSelectionParams({ + modelSdkId: "composer-2", + reasoningEffort: "high", + fastMode: true, + })).toEqual([ + { id: "reasoning_effort", value: "high" }, + { id: "speed", value: "fast" }, + ]); + }); + it("falls back to Cursor's official models API when SDK model listing fails", async () => { cursorModelsListMock.mockRejectedValue(new Error("SDK model listing failed")); const fetchMock = vi.fn(async () => ({ @@ -159,12 +211,12 @@ describe("parseCursorCliModelsStdout", () => { expect(reportProviderRuntimeReadyMock).not.toHaveBeenCalled(); }); - it("uses only conservative fallback rows when Cursor model APIs cannot enumerate", async () => { + it("returns no Cursor SDK rows when model APIs cannot enumerate", async () => { cursorModelsListMock.mockRejectedValue(new Error("SDK model listing failed")); vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 503 }))); const descriptors = await discoverCursorSdkModelDescriptors("crsr_test", { mode: "probe" }); - expect(descriptors.map((descriptor) => descriptor.id)).toEqual(["cursor/auto", "cursor/composer-2"]); + expect(descriptors).toEqual([]); }); it("does not show fallback models when Cursor rejects agent/model auth", async () => { @@ -203,8 +255,8 @@ describe("parseCursorCliModelsStdout", () => { const fetchMock = vi.fn(async () => ({ ok: false, status: 401 })); vi.stubGlobal("fetch", fetchMock); - const initial = await discoverCursorSdkModelDescriptors("crsr_test"); - expect(initial.map((descriptor) => descriptor.id)).toEqual(["cursor/auto", "cursor/composer-2"]); + const initial = await discoverCursorSdkModelDescriptors("crsr_test", { mode: "cached-or-fallback" }); + expect(initial).toEqual([]); await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalled(); }); diff --git a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts index 2036e856e..0330eb09f 100644 --- a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts +++ b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts @@ -11,7 +11,27 @@ import { reportProviderRuntimeReady, } from "../ai/providerRuntimeHealth"; -export type CursorCliModelRow = { id: string; displayName?: string }; +export type CursorModelParameterValue = { id: string; value: string }; +export type CursorModelParameterDefinition = { + id: string; + displayName?: string; + values: Array<{ value: string; displayName?: string }>; +}; +export type CursorModelVariant = { + params: CursorModelParameterValue[]; + displayName: string; + description?: string; + isDefault?: boolean; +}; +export type CursorCliModelRow = { + id: string; + displayName?: string; + description?: string; + parameters?: CursorModelParameterDefinition[]; + variants?: CursorModelVariant[]; + reasoningTiers?: string[]; + serviceTiers?: string[]; +}; type CursorCliModelDiscoveryMode = "probe" | "cached-or-fallback" | "cached-only"; export type CursorModelDiscoveryFailureKind = "auth" | "timeout" | "unavailable"; export type CursorSdkModelDiscoveryResult = { @@ -42,15 +62,6 @@ class CursorModelDiscoveryError extends Error { } } -const MINIMAL_FALLBACK_SDK_ROWS: CursorCliModelRow[] = [ - { id: "auto", displayName: "Auto" }, - { id: "composer-2", displayName: "Composer 2" }, -]; - -function fallbackCursorSdkRows(): CursorCliModelRow[] { - return MINIMAL_FALLBACK_SDK_ROWS; -} - function stripAnsi(text: string): string { return text.replace(/\u001b\[[0-9;]*m/g, ""); } @@ -107,6 +118,151 @@ function readErrorMessage(error: unknown): string { return String(error ?? "Unknown Cursor model discovery error."); } +function normalizeCursorMetadataText(value: unknown): string { + return String(value ?? "").trim().toLowerCase().replace(/[_\s]+/g, "-"); +} + +function addUnique(out: string[], value: string | null | undefined): void { + const normalized = normalizeCursorMetadataText(value); + if (!normalized) return; + if (!out.some((entry) => normalizeCursorMetadataText(entry) === normalized)) { + out.push(normalized); + } +} + +function isReasoningParameterLike(parameter: Pick): boolean { + const hay = `${parameter.id} ${parameter.displayName ?? ""}`.toLowerCase(); + return /\b(reason|reasoning|thinking|think|effort)\b/.test(hay); +} + +function isServiceTierParameterLike(parameter: Pick): boolean { + const hay = `${parameter.id} ${parameter.displayName ?? ""}`.toLowerCase(); + return /\b(speed|service|tier|mode|latency)\b/.test(hay); +} + +function normalizeCursorReasoningValue(value: unknown): string | null { + const normalized = normalizeCursorMetadataText(value); + if (!normalized) return null; + if (normalized === "extra-high" || normalized === "extra_high") return "xhigh"; + if ([ + "none", + "dynamic", + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "thinking", + ].includes(normalized)) { + return normalized; + } + return null; +} + +function normalizeCursorServiceTierValue(value: unknown): string | null { + const normalized = normalizeCursorMetadataText(value); + return normalized === "fast" ? "fast" : null; +} + +function normalizeCursorParameterDefinitions(value: unknown): CursorModelParameterDefinition[] | undefined { + if (!Array.isArray(value)) return undefined; + const out: CursorModelParameterDefinition[] = []; + for (const entry of value) { + const record = entry && typeof entry === "object" ? entry as Record : null; + const id = typeof record?.id === "string" ? record.id.trim() : ""; + const rawValues = Array.isArray(record?.values) ? record.values : []; + if (!id || !rawValues.length) continue; + const values = rawValues.flatMap((rawValue): Array<{ value: string; displayName?: string }> => { + if (typeof rawValue === "string") return rawValue.trim() ? [{ value: rawValue.trim() }] : []; + const valueRecord = rawValue && typeof rawValue === "object" ? rawValue as Record : null; + const value = typeof valueRecord?.value === "string" ? valueRecord.value.trim() : ""; + if (!value) return []; + const displayName = typeof valueRecord?.displayName === "string" && valueRecord.displayName.trim().length + ? valueRecord.displayName.trim() + : undefined; + return displayName ? [{ value, displayName }] : [{ value }]; + }); + if (!values.length) continue; + const displayName = typeof record?.displayName === "string" && record.displayName.trim().length + ? record.displayName.trim() + : undefined; + out.push(displayName ? { id, displayName, values } : { id, values }); + } + return out.length ? out : undefined; +} + +function normalizeCursorModelVariants(value: unknown): CursorModelVariant[] | undefined { + if (!Array.isArray(value)) return undefined; + const out: CursorModelVariant[] = []; + for (const entry of value) { + const record = entry && typeof entry === "object" ? entry as Record : null; + const displayName = typeof record?.displayName === "string" ? record.displayName.trim() : ""; + const rawParams = Array.isArray(record?.params) ? record.params : []; + if (!displayName || !rawParams.length) continue; + const params = rawParams.flatMap((param): CursorModelParameterValue[] => { + const paramRecord = param && typeof param === "object" ? param as Record : null; + const id = typeof paramRecord?.id === "string" ? paramRecord.id.trim() : ""; + const value = typeof paramRecord?.value === "string" ? paramRecord.value.trim() : ""; + return id && value ? [{ id, value }] : []; + }); + if (!params.length) continue; + const description = typeof record?.description === "string" && record.description.trim().length + ? record.description.trim() + : undefined; + const isDefault = record?.isDefault === true; + out.push({ + params, + displayName, + ...(description ? { description } : {}), + ...(isDefault ? { isDefault } : {}), + }); + } + return out.length ? out : undefined; +} + +function deriveCursorRuntimeTiers(row: Pick): { + reasoningTiers: string[]; + serviceTiers: string[]; +} { + const reasoningTiers: string[] = []; + const serviceTiers: string[] = []; + const reasoningParameterIds = new Set( + (row.parameters ?? []).filter(isReasoningParameterLike).map((entry) => entry.id), + ); + const serviceTierParameterIds = new Set( + (row.parameters ?? []).filter(isServiceTierParameterLike).map((entry) => entry.id), + ); + + for (const parameter of row.parameters ?? []) { + if (reasoningParameterIds.has(parameter.id)) { + for (const value of parameter.values) { + addUnique(reasoningTiers, normalizeCursorReasoningValue(value.value) ?? normalizeCursorReasoningValue(value.displayName)); + } + } + if (serviceTierParameterIds.has(parameter.id)) { + for (const value of parameter.values) { + addUnique(serviceTiers, normalizeCursorServiceTierValue(value.value) ?? normalizeCursorServiceTierValue(value.displayName)); + } + } + } + + for (const variant of row.variants ?? []) { + const label = `${variant.displayName} ${variant.description ?? ""}`; + for (const param of variant.params) { + if (reasoningParameterIds.has(param.id) || /\b(reason|thinking|effort)\b/i.test(label)) { + addUnique(reasoningTiers, normalizeCursorReasoningValue(param.value) ?? normalizeCursorReasoningValue(label)); + } + if (serviceTierParameterIds.has(param.id) || /\bfast\b/i.test(label)) { + addUnique(serviceTiers, normalizeCursorServiceTierValue(param.value) ?? normalizeCursorServiceTierValue(label)); + } + } + } + + return { reasoningTiers, serviceTiers }; +} + function readErrorStatus(error: unknown): number | null { if (!error || typeof error !== "object") return null; const record = error as Record; @@ -175,7 +331,21 @@ function normalizeSdkModelRows(models: SDKModel[]): CursorCliModelRow[] { if (!id || seen.has(id)) continue; seen.add(id); const displayName = typeof model.displayName === "string" ? model.displayName.trim() : ""; - rows.push(displayName ? { id, displayName } : { id }); + const description = typeof model.description === "string" && model.description.trim().length + ? model.description.trim() + : undefined; + const parameters = normalizeCursorParameterDefinitions((model as { parameters?: unknown }).parameters); + const variants = normalizeCursorModelVariants((model as { variants?: unknown }).variants); + const tiers = deriveCursorRuntimeTiers({ parameters, variants }); + rows.push({ + id, + ...(displayName ? { displayName } : {}), + ...(description ? { description } : {}), + ...(parameters ? { parameters } : {}), + ...(variants ? { variants } : {}), + ...(tiers.reasoningTiers.length ? { reasoningTiers: tiers.reasoningTiers } : {}), + ...(tiers.serviceTiers.length ? { serviceTiers: tiers.serviceTiers } : {}), + }); } return rows; } @@ -206,7 +376,17 @@ function normalizeCursorModelRows(models: unknown[]): CursorCliModelRow[] { : typeof record.name === "string" ? record.name.trim() : ""; - rows.push(displayName ? { id, displayName } : { id }); + const parameters = normalizeCursorParameterDefinitions(record.parameters); + const variants = normalizeCursorModelVariants(record.variants); + const tiers = deriveCursorRuntimeTiers({ parameters, variants }); + rows.push({ + id, + ...(displayName ? { displayName } : {}), + ...(parameters ? { parameters } : {}), + ...(variants ? { variants } : {}), + ...(tiers.reasoningTiers.length ? { reasoningTiers: tiers.reasoningTiers } : {}), + ...(tiers.serviceTiers.length ? { serviceTiers: tiers.serviceTiers } : {}), + }); } return rows; } @@ -394,11 +574,81 @@ function cursorRowsToDescriptors(rows: CursorCliModelRow[]): ModelDescriptor[] { const id = String(row.id ?? "").trim(); if (!id || seen.has(id)) continue; seen.add(id); - descriptors.push(createDynamicCursorCliModelDescriptor(id, row.displayName)); + descriptors.push(createDynamicCursorCliModelDescriptor(id, row.displayName, { + ...(row.reasoningTiers?.length ? { reasoningTiers: row.reasoningTiers } : {}), + ...(row.serviceTiers?.length ? { serviceTiers: row.serviceTiers } : {}), + })); } return sortCursorCliDescriptorsForPicker(descriptors); } +export function resolveCursorSdkModelSelectionParams(args: { + modelSdkId: string; + reasoningEffort?: string | null; + fastMode?: boolean | null; +}): CursorModelParameterValue[] | undefined { + const modelSdkId = args.modelSdkId.trim(); + if (!modelSdkId || !sdkCached?.models.length) return undefined; + const row = sdkCached.models.find((entry) => entry.id === modelSdkId); + if (!row) return undefined; + const reasoning = normalizeCursorMetadataText(args.reasoningEffort); + const wantsFast = args.fastMode === true; + const out = new Map(); + const applyParams = (params: readonly CursorModelParameterValue[]): void => { + for (const param of params) { + if (param.id.trim() && param.value.trim()) out.set(param.id.trim(), param.value.trim()); + } + }; + + const reasoningParameterIds = new Set( + (row.parameters ?? []).filter(isReasoningParameterLike).map((entry) => entry.id), + ); + const serviceTierParameterIds = new Set( + (row.parameters ?? []).filter(isServiceTierParameterLike).map((entry) => entry.id), + ); + + if (reasoning) { + const matchingVariant = (row.variants ?? []).find((variant) => { + const label = normalizeCursorMetadataText(`${variant.displayName} ${variant.description ?? ""}`); + return variant.params.some((param) => + reasoningParameterIds.has(param.id) + && normalizeCursorMetadataText(param.value) === reasoning, + ) || label.includes(reasoning); + }); + if (matchingVariant) applyParams(matchingVariant.params); + for (const parameter of row.parameters ?? []) { + if (!reasoningParameterIds.has(parameter.id)) continue; + const value = parameter.values.find((entry) => + normalizeCursorMetadataText(entry.value) === reasoning + || normalizeCursorMetadataText(entry.displayName) === reasoning, + ); + if (value) out.set(parameter.id, value.value); + } + } + + if (wantsFast) { + const matchingVariant = (row.variants ?? []).find((variant) => { + const label = normalizeCursorMetadataText(`${variant.displayName} ${variant.description ?? ""}`); + return variant.params.some((param) => + serviceTierParameterIds.has(param.id) + && normalizeCursorServiceTierValue(param.value) === "fast", + ) || label.includes("fast"); + }); + if (matchingVariant) applyParams(matchingVariant.params); + for (const parameter of row.parameters ?? []) { + if (!serviceTierParameterIds.has(parameter.id)) continue; + const value = parameter.values.find((entry) => + normalizeCursorServiceTierValue(entry.value) === "fast" + || normalizeCursorServiceTierValue(entry.displayName) === "fast", + ); + if (value) out.set(parameter.id, value.value); + } + } + + const params = [...out.entries()].map(([id, value]) => ({ id, value })); + return params.length ? params : undefined; +} + /** * Best-effort: run `agent models` (and JSON variants) and parse stdout. */ @@ -470,11 +720,10 @@ export async function discoverCursorCliModelDescriptors( agentPath: string, options?: { mode?: CursorCliModelDiscoveryMode }, ): Promise { - const rows = options?.mode === "cached-or-fallback" + const rows = options?.mode === "cached-or-fallback" || options?.mode === "cached-only" ? getCachedCursorModels() ?? [] : await listCursorModelsFromCli(agentPath); - const useRows: CursorCliModelRow[] = rows.length ? rows : fallbackCursorSdkRows(); - return cursorRowsToDescriptors(useRows); + return cursorRowsToDescriptors(rows); } export async function discoverCursorSdkModelDescriptors( @@ -487,13 +736,9 @@ export async function discoverCursorSdkModelDescriptors( const rows = result?.rows ?? getCachedCursorSdkModels(apiKey) ?? []; const recentFailure = getRecentCursorSdkFailure(apiKey); const knownAuthFailure = result?.failureKind === "auth" || recentFailure?.kind === "auth"; - if (!rows.length && options?.mode !== "probe") { + if (!rows.length && options?.mode === "cached-or-fallback") { warmCursorModelsFromSdk(apiKey); } - const useRows: CursorCliModelRow[] = rows.length - ? rows - : options?.mode === "cached-only" || knownAuthFailure - ? [] - : fallbackCursorSdkRows(); - return cursorRowsToDescriptors(useRows); + void knownAuthFailure; + return cursorRowsToDescriptors(rows); } diff --git a/apps/desktop/src/main/services/chat/cursorSdkPool.ts b/apps/desktop/src/main/services/chat/cursorSdkPool.ts index d52c5ccee..dbc9124b1 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPool.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPool.ts @@ -8,6 +8,7 @@ import type { CursorSdkCloudArtifactDescriptor, CursorSdkHookDecision, CursorSdkHookRequest, + CursorSdkModelParameterValue, CursorSdkPermissionPolicy, CursorSdkRuntime, CursorSdkSendPrompt, @@ -187,6 +188,7 @@ export async function acquireCursorSdkConnection(args: { projectRoot: string; workspacePath: string; modelSdkId: string; + modelParams?: CursorSdkModelParameterValue[]; apiKey?: string | null; agentId?: string | null; agentName?: string | null; @@ -422,6 +424,7 @@ async function createCursorSdkConnection(args: Parameters(); + for (const param of params ?? []) { + const id = param.id.trim(); + const value = param.value.trim(); + if (!id || !value || seen.has(id)) continue; + seen.add(id); + out.push({ id, value }); + } + return out.length ? out : undefined; +} + +function buildCursorModelSelection( + modelSdkId: string | null | undefined, + params?: CursorSdkModelParameterValue[], +): { id: string; params?: CursorSdkModelParameterValue[] } | undefined { + const id = modelSdkId?.trim(); + if (!id) return undefined; + const normalizedParams = normalizeCursorModelParams(params); + return normalizedParams ? { id, params: normalizedParams } : { id }; +} + async function initWorker(init: CursorSdkWorkerInit): Promise<{ agentId: string; modelSdkId: string }> { initState = init; process.env.HOME = init.userHomeDir; @@ -192,7 +216,7 @@ async function initWorker(init: CursorSdkWorkerInit): Promise<{ agentId: string; // state, sandboxOptions is terminal policy, and hooks gate tool execution. const agentOptions: AgentOptions = { apiKey: init.apiKey?.trim() || undefined, - model: { id: init.modelSdkId }, + model: buildCursorModelSelection(init.modelSdkId, init.modelParams), name: init.agentName ?? undefined, local: { cwd: init.laneRoot, @@ -223,13 +247,19 @@ async function initWorker(init: CursorSdkWorkerInit): Promise<{ agentId: string; return { agentId: agent.agentId, modelSdkId: init.modelSdkId }; } -async function sendPrompt(payload: { promptText: string; images?: Array<{ data: string; mimeType: string }>; modelSdkId?: string | null; force?: boolean }): Promise { +async function sendPrompt(payload: { + promptText: string; + images?: Array<{ data: string; mimeType: string }>; + modelSdkId?: string | null; + modelParams?: CursorSdkModelParameterValue[]; + force?: boolean; +}): Promise { if (!agent || !initState) throw new Error("Cursor SDK worker is not initialized."); const message = payload.images?.length ? { text: payload.promptText, images: payload.images } : payload.promptText; currentRun = await agent.send(message, { - model: payload.modelSdkId?.trim() ? { id: payload.modelSdkId.trim() } : undefined, + model: buildCursorModelSelection(payload.modelSdkId, payload.modelParams), local: { force: payload.force === true }, }); post({ @@ -330,7 +360,8 @@ function buildCloudCreateOptions(payload: CursorSdkCloudSendStreamPayload): Agen name: payload.agentName?.trim() || undefined, cloud, }; - if (payload.modelSdkId?.trim()) options.model = { id: payload.modelSdkId.trim() }; + const modelSelection = buildCursorModelSelection(payload.modelSdkId, payload.modelParams); + if (modelSelection) options.model = modelSelection; return options; } @@ -338,7 +369,8 @@ function buildCloudResumeOptions(payload: CursorSdkCloudFollowupPayload): Partia const options: Partial = { apiKey: payload.apiKey?.trim() || undefined, }; - if (payload.modelSdkId?.trim()) options.model = { id: payload.modelSdkId.trim() }; + const modelSelection = buildCursorModelSelection(payload.modelSdkId, payload.modelParams); + if (modelSelection) options.model = modelSelection; return options; } @@ -478,7 +510,8 @@ async function handleCloudRequest(req: CursorSdkWorkerRequest): Promise } if (req.type === "cloud.send.stream") { const cloudAgent = await Agent.create(buildCloudCreateOptions(req.payload)); - const sendOpts = req.payload.modelSdkId?.trim() ? { model: { id: req.payload.modelSdkId.trim() } } : undefined; + const modelSelection = buildCursorModelSelection(req.payload.modelSdkId, req.payload.modelParams); + const sendOpts = modelSelection ? { model: modelSelection } : undefined; const run = await cloudAgent.send(req.payload.promptText, sendOpts); const result = await streamCloudRun({ requestId: req.requestId, @@ -498,7 +531,8 @@ async function handleCloudRequest(req: CursorSdkWorkerRequest): Promise } if (req.type === "cloud.followup") { const cloudAgent = await Agent.resume(req.payload.agentId, buildCloudResumeOptions(req.payload)); - const sendOpts = req.payload.modelSdkId?.trim() ? { model: { id: req.payload.modelSdkId.trim() } } : undefined; + const modelSelection = buildCursorModelSelection(req.payload.modelSdkId, req.payload.modelParams); + const sendOpts = modelSelection ? { model: modelSelection } : undefined; const run = await cloudAgent.send(req.payload.promptText, sendOpts); const result = await streamCloudRun({ requestId: req.requestId, diff --git a/apps/desktop/src/main/services/chat/droidModelsDiscovery.test.ts b/apps/desktop/src/main/services/chat/droidModelsDiscovery.test.ts index bcef15721..15c53e075 100644 --- a/apps/desktop/src/main/services/chat/droidModelsDiscovery.test.ts +++ b/apps/desktop/src/main/services/chat/droidModelsDiscovery.test.ts @@ -125,6 +125,37 @@ describe("discoverDroidCliModelDescriptors", () => { }); }); + it("preserves Droid SDK reasoning and media metadata", async () => { + const close = vi.fn(async () => {}); + mockCreateSession.mockResolvedValueOnce({ + initResult: { + availableModels: [ + { + id: "gpt-5.4", + displayName: "GPT-5.4", + supportedReasoningEfforts: ["low", "high", "max"], + defaultReasoningEffort: "high", + tier: "fast", + noImageSupport: true, + }, + ], + }, + close, + }); + + const descriptors = await discoverDroidCliModelDescriptors("/mock/bin/droid"); + + expect(descriptors[0]).toMatchObject({ + id: "droid/gpt-5.4", + reasoningTiers: ["high", "low", "max"], + serviceTiers: ["fast"], + capabilities: expect.objectContaining({ + vision: false, + reasoning: true, + }), + }); + }); + it("merges existing Factory config custom models with SDK models", async () => { fs.mkdirSync(path.join(tmpHome, ".factory"), { recursive: true }); fs.writeFileSync( diff --git a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts index 8895ef41a..496843c57 100644 --- a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts +++ b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts @@ -5,6 +5,7 @@ import { createSession } from "@factory/droid-sdk"; import { createDynamicDroidCliModelDescriptor, sortDroidCliDescriptorsForPicker, + type ModelCapabilities, type ModelDescriptor, } from "../../../shared/modelRegistry"; import { spawnAsync } from "../shared/utils"; @@ -14,6 +15,9 @@ export type DroidExecHelpModelRow = { displayName: string; /** True when sourced from ~/.factory/config.json (vibeproxy / custom proxy). */ customProxy?: boolean; + reasoningTiers?: string[]; + serviceTiers?: string[]; + capabilities?: Partial; }; type DroidCliModelDiscoveryMode = "probe" | "cached-or-fallback"; @@ -158,6 +162,50 @@ async function listDroidModelsFromCliInner(droidPath: string): Promise): string[] | undefined { + const out: string[] = []; + const tier = typeof model.tier === "string" ? model.tier.trim().toLowerCase() : ""; + const promo = typeof model.promoLabel === "string" ? model.promoLabel.trim().toLowerCase() : ""; + if (tier === "fast" || /\bfast\b/.test(promo)) out.push("fast"); + return out.length ? out : undefined; +} + function readSdkModelRows(initResult: unknown): DroidExecHelpModelRow[] { const record = initResult && typeof initResult === "object" ? initResult as Record : null; const raw = Array.isArray(record?.availableModels) ? record.availableModels : []; @@ -178,10 +226,21 @@ function readSdkModelRows(initResult: unknown): DroidExecHelpModelRow[] { : typeof model.shortDisplayName === "string" && model.shortDisplayName.trim().length ? model.shortDisplayName.trim() : id; + const reasoningTiers = normalizeDroidReasoningEfforts( + model.supportedReasoningEfforts, + model.defaultReasoningEffort, + ); + const serviceTiers = normalizeDroidServiceTiers(model); rows.push({ id, displayName, customProxy: model.isCustom === true, + ...(reasoningTiers?.length ? { reasoningTiers } : {}), + ...(serviceTiers?.length ? { serviceTiers } : {}), + capabilities: { + vision: model.noImageSupport !== true, + reasoning: Boolean(reasoningTiers?.length), + }, }); } return rows; @@ -273,7 +332,12 @@ export async function discoverDroidCliModelDescriptors( const trimmed = String(row.id ?? "").trim(); if (!trimmed || seen.has(trimmed)) continue; seen.add(trimmed); - descriptors.push(createDynamicDroidCliModelDescriptor(trimmed, row.displayName, { customProxy: row.customProxy })); + descriptors.push(createDynamicDroidCliModelDescriptor(trimmed, row.displayName, { + customProxy: row.customProxy, + ...(row.reasoningTiers?.length ? { reasoningTiers: row.reasoningTiers } : {}), + ...(row.serviceTiers?.length ? { serviceTiers: row.serviceTiers } : {}), + ...(row.capabilities ? { capabilities: row.capabilities } : {}), + })); } return sortDroidCliDescriptorsForPicker(descriptors); } diff --git a/apps/desktop/src/main/services/chat/droidSdkProtocol.ts b/apps/desktop/src/main/services/chat/droidSdkProtocol.ts index c2f8c9bbd..fac91526b 100644 --- a/apps/desktop/src/main/services/chat/droidSdkProtocol.ts +++ b/apps/desktop/src/main/services/chat/droidSdkProtocol.ts @@ -8,7 +8,8 @@ export type DroidSdkReasoningEffort = | "low" | "medium" | "high" - | "xhigh"; + | "xhigh" + | "max"; export type DroidSdkSessionSettings = { modelId: string; diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index a16f3a0fe..37ee8f84a 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -6693,6 +6693,11 @@ export function registerIpc({ return await ctx.agentChatService.getAvailableModels(arg); }); + ipcMain.handle(IPC.agentChatModelCatalog, async (_event, arg: unknown) => { + const ctx = getCtx(); + return await ctx.agentChatService.getModelCatalog(arg && typeof arg === "object" ? arg as never : undefined); + }); + ipcMain.handle(IPC.agentChatArchive, async (_event, arg: AgentChatArchiveArgs): Promise => { const ctx = getCtx(); await ctx.agentChatService.archiveSession(arg); diff --git a/apps/desktop/src/main/services/opencode/openCodeBinaryManager.test.ts b/apps/desktop/src/main/services/opencode/openCodeBinaryManager.test.ts new file mode 100644 index 000000000..7461bb823 --- /dev/null +++ b/apps/desktop/src/main/services/opencode/openCodeBinaryManager.test.ts @@ -0,0 +1,86 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearOpenCodeBinaryCache, + resolveOpenCodeBinary, +} from "./openCodeBinaryManager"; + +const originalEnv = { + HOME: process.env.HOME, + PATH: process.env.PATH, + SHELL: process.env.SHELL, +}; + +function makeExecutable(filePath: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(filePath, 0o755); +} + +function enoent(): NodeJS.ErrnoException { + const error: NodeJS.ErrnoException = new Error("ENOENT"); + error.code = "ENOENT"; + return error; +} + +describe("openCodeBinaryManager", () => { + let tempRoot: string; + let homeDir: string; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-opencode-bin-")); + homeDir = path.join(tempRoot, "home"); + fs.mkdirSync(homeDir, { recursive: true }); + process.env.HOME = homeDir; + process.env.PATH = "/usr/bin:/bin"; + process.env.SHELL = "/bin/false"; + clearOpenCodeBinaryCache(); + + const realStatSync = fs.statSync; + vi.spyOn(fs, "statSync").mockImplementation(((candidatePath: fs.PathLike, opts?: any) => { + const normalized = path.normalize(String(candidatePath)); + const normalizedRoot = path.normalize(tempRoot); + if (path.basename(normalized) === "opencode" && !normalized.startsWith(normalizedRoot)) { + throw enoent(); + } + return realStatSync(normalized, opts); + }) as typeof fs.statSync); + }); + + afterEach(() => { + clearOpenCodeBinaryCache(); + vi.restoreAllMocks(); + process.env.HOME = originalEnv.HOME; + process.env.PATH = originalEnv.PATH; + process.env.SHELL = originalEnv.SHELL; + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it("rechecks after a missing result so a newly installed OpenCode binary is discovered", () => { + expect(resolveOpenCodeBinary()).toEqual({ path: null, source: "missing" }); + + const binaryPath = path.join(homeDir, ".npm-global", "bin", "opencode"); + makeExecutable(binaryPath); + + expect(resolveOpenCodeBinary()).toEqual({ + path: binaryPath, + source: "user-installed", + }); + }); + + it("invalidates a cached positive path if the binary disappears", () => { + const binaryPath = path.join(homeDir, ".npm-global", "bin", "opencode"); + makeExecutable(binaryPath); + + expect(resolveOpenCodeBinary()).toEqual({ + path: binaryPath, + source: "user-installed", + }); + + fs.rmSync(binaryPath); + + expect(resolveOpenCodeBinary()).toEqual({ path: null, source: "missing" }); + }); +}); diff --git a/apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts b/apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts index 3ac868676..567b7a275 100644 --- a/apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts +++ b/apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts @@ -33,7 +33,7 @@ function bundledBinaryCandidatePaths(): string[] { return fileNames.map((fileName) => join(__dirname, "..", "..", "..", "..", "node_modules", ".bin", fileName)); } -function canRunBundledBinary(filePath: string): boolean { +function canRunBinaryCandidate(filePath: string): boolean { try { accessSync(filePath, process.platform === "win32" ? constants.F_OK : constants.X_OK); return true; @@ -43,7 +43,10 @@ function canRunBundledBinary(filePath: string): boolean { } export function resolveOpenCodeBinary(): OpenCodeBinaryInfo { - if (cachedInfo) return cachedInfo; + if (cachedInfo?.path && canRunBinaryCandidate(cachedInfo.path)) { + return cachedInfo; + } + cachedInfo = null; // Ensure PATH includes shell paths and known CLI dirs before searching. // On Windows, `process.env.PATH = …` can create a duplicate `PATH` key @@ -59,14 +62,16 @@ export function resolveOpenCodeBinary(): OpenCodeBinaryInfo { } // 2. Fall back to bundled binary - const bundled = bundledBinaryCandidatePaths().find((candidate) => canRunBundledBinary(candidate)); + const bundled = bundledBinaryCandidatePaths().find((candidate) => canRunBinaryCandidate(candidate)); if (bundled) { cachedInfo = { path: bundled, source: "bundled" }; return cachedInfo; } - cachedInfo = { path: null, source: "missing" }; - return cachedInfo; + // Do not cache misses. Users can install OpenCode or fix PATH while ADE is + // running, and the picker/settings cheap probe should pick that up without + // requiring a main-process restart. + return { path: null, source: "missing" }; } export function resolveOpenCodeBinaryPath(): string | null { diff --git a/apps/desktop/src/main/services/opencode/openCodeInventory.test.ts b/apps/desktop/src/main/services/opencode/openCodeInventory.test.ts index 6d793b653..cab2a555b 100644 --- a/apps/desktop/src/main/services/opencode/openCodeInventory.test.ts +++ b/apps/desktop/src/main/services/opencode/openCodeInventory.test.ts @@ -91,7 +91,7 @@ describe("openCodeInventory", () => { expect(mockState.shutdownOpenCodeServers).toHaveBeenCalledWith({ leaseKind: "shared", ownerKind: "inventory" }); }); - it("does not filter local providers when discovery data is absent", async () => { + it("filters local providers when discovery data is absent", async () => { const logger = { warn: vi.fn() } as any; mockState.providerList.mockResolvedValueOnce({ data: { @@ -121,8 +121,103 @@ describe("openCodeInventory", () => { force: true, }); - expect(result.modelIds).toContain("opencode/ollama/llama-3.1"); - expect(result.descriptors).toHaveLength(1); + expect(result.modelIds).not.toContain("opencode/ollama/llama-3.1"); + expect(result.catalogModelIds).not.toContain("opencode/ollama/llama-3.1"); + expect(result.descriptors).toHaveLength(0); + }); + + it("keeps unconnected cloud providers in the browseable catalog", async () => { + const logger = { warn: vi.fn() } as any; + mockState.providerList.mockResolvedValueOnce({ + data: { + connected: ["openai"], + all: [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + tool_call: true, + reasoning: true, + }, + }, + }, + { + id: "anthropic", + name: "Anthropic", + models: { + "claude-sonnet-4-6": { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + tool_call: true, + reasoning: true, + }, + }, + }, + ], + }, + } as any); + + const result = await probeOpenCodeProviderInventory({ + projectRoot: "/repo", + projectConfig: { ai: {} }, + logger, + force: true, + }); + + expect(result.catalogModelIds).toContain("opencode/anthropic/claude-sonnet-4-6"); + expect(result.modelIds).not.toContain("opencode/anthropic/claude-sonnet-4-6"); + expect(result.providers.find((provider) => provider.id === "anthropic")?.connected).toBe(false); + }); + + it("classifies OpenCode SDK model variants and v2 capabilities", async () => { + const logger = { warn: vi.fn() } as any; + mockState.providerList.mockResolvedValueOnce({ + data: { + connected: ["openai"], + all: [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + capabilities: { + reasoning: true, + toolcall: true, + input: { image: true }, + }, + variants: { + low: {}, + high: {}, + fast: {}, + disabled: { disabled: true }, + }, + }, + }, + }, + ], + }, + } as any); + + const result = await probeOpenCodeProviderInventory({ + projectRoot: "/repo", + projectConfig: { ai: {} }, + logger, + force: true, + }); + + const descriptor = result.descriptors.find((entry) => entry.id === "opencode/openai/gpt-5.4"); + expect(descriptor?.reasoningTiers).toEqual(["low", "high"]); + expect(descriptor?.serviceTiers).toEqual(["fast"]); + expect(descriptor?.capabilities).toMatchObject({ + tools: true, + vision: true, + reasoning: true, + }); }); it("allows passive cache reads after a probe warmed inventory with discovered local models", async () => { diff --git a/apps/desktop/src/main/services/opencode/openCodeInventory.ts b/apps/desktop/src/main/services/opencode/openCodeInventory.ts index 89cd59051..8aee076db 100644 --- a/apps/desktop/src/main/services/opencode/openCodeInventory.ts +++ b/apps/desktop/src/main/services/opencode/openCodeInventory.ts @@ -26,6 +26,7 @@ export type OpenCodeProviderInfo = { name: string; connected: boolean; modelCount: number; + availableModelCount?: number; }; type CacheEntry = { @@ -33,13 +34,24 @@ type CacheEntry = { projectRoot: string; configFingerprint: string; passiveConfigFingerprint: string; + catalogModelIds: string[]; modelIds: string[]; providers: OpenCodeProviderInfo[]; error: string | null; }; let inventoryCache: CacheEntry | null = null; -const probeInFlightMap = new Map>(); +const probeInFlightMap = new Map>(); + +export type OpenCodeInventoryResult = { + /** Selectable model ids for connected providers only. */ + modelIds: string[]; + /** Browseable catalog model ids. Unconnected cloud providers appear here but not in modelIds. */ + catalogModelIds: string[]; + providers: OpenCodeProviderInfo[]; + error: string | null; + descriptors: ModelDescriptor[]; +}; export function clearOpenCodeInventoryCache(): void { inventoryCache = null; @@ -63,12 +75,80 @@ function fingerprintOpenCodeConfig( }); } -function extractVariantKeys(model: Record): string[] { +const OPENCODE_REASONING_VARIANT_ALIASES: Record = { + none: "none", + dynamic: "dynamic", + off: "off", + minimal: "minimal", + mini: "minimal", + low: "low", + medium: "medium", + med: "medium", + high: "high", + xhigh: "xhigh", + "extra-high": "xhigh", + extra_high: "xhigh", + max: "max", +}; + +const OPENCODE_SERVICE_VARIANT_ALIASES: Record = { + fast: "fast", +}; + +function addUnique(out: string[], value: string): void { + if (!out.some((entry) => entry.trim().toLowerCase() === value)) out.push(value); +} + +function normalizeVariantKey(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, "-"); +} + +function classifyOpenCodeVariants(model: Record): { + reasoningTiers: string[]; + serviceTiers: string[]; +} { const v = model.variants; + const reasoningTiers: string[] = []; + const serviceTiers: string[] = []; if (v && typeof v === "object" && !Array.isArray(v)) { - return Object.keys(v as Record).filter(Boolean); + for (const [rawKey, rawValue] of Object.entries(v as Record)) { + const key = normalizeVariantKey(rawKey); + if (!key) continue; + if (rawValue && typeof rawValue === "object" && (rawValue as { disabled?: unknown }).disabled === true) { + continue; + } + const serviceTier = OPENCODE_SERVICE_VARIANT_ALIASES[key]; + if (serviceTier) { + addUnique(serviceTiers, serviceTier); + continue; + } + addUnique(reasoningTiers, OPENCODE_REASONING_VARIANT_ALIASES[key] ?? key); + } } - return []; + return { reasoningTiers, serviceTiers }; +} + +function readOpenCodeModelCapabilities(model: Record): { + tools: boolean; + vision: boolean; + reasoning: boolean; + streaming: boolean; +} { + const capabilities = model.capabilities && typeof model.capabilities === "object" + ? model.capabilities as { + reasoning?: unknown; + toolcall?: unknown; + tools?: unknown; + input?: { image?: unknown }; + } + : null; + const modalities = model.modalities as { input?: string[] } | undefined; + return { + tools: model.tool_call !== false && capabilities?.toolcall !== false && capabilities?.tools !== false, + vision: Boolean(modalities?.input?.includes("image") || capabilities?.input?.image === true), + reasoning: model.reasoning !== false && capabilities?.reasoning !== false, + streaming: true, + }; } /** @@ -82,11 +162,11 @@ export async function probeOpenCodeProviderInventory(args: { force?: boolean; /** Dynamically discovered models from local provider endpoints (LM Studio, Ollama). */ discoveredLocalModels?: DiscoveredLocalModelEntry[]; -}): Promise<{ modelIds: string[]; providers: OpenCodeProviderInfo[]; error: string | null; descriptors: ModelDescriptor[] }> { +}): Promise { if (!resolveOpenCodeExecutablePath()) { replaceDynamicOpenCodeModelDescriptors([]); inventoryCache = null; - return { modelIds: [], providers: [], error: null, descriptors: [] }; + return { modelIds: [], catalogModelIds: [], providers: [], error: null, descriptors: [] }; } const fp = fingerprintOpenCodeConfig(args.projectConfig, args.discoveredLocalModels); @@ -99,11 +179,12 @@ export async function probeOpenCodeProviderInventory(args: { && inventoryCache.configFingerprint === fp && now - inventoryCache.cachedAt < TTL_MS ) { - return { - modelIds: inventoryCache.modelIds, - providers: inventoryCache.providers, - error: inventoryCache.error, - descriptors: [], + return { + modelIds: inventoryCache.modelIds, + catalogModelIds: inventoryCache.catalogModelIds, + providers: inventoryCache.providers, + error: inventoryCache.error, + descriptors: [], }; } @@ -149,16 +230,7 @@ export async function probeOpenCodeProviderInventory(args: { } const connected = new Set(data.connected); const descriptors: ModelDescriptor[] = []; - const providerInfos: OpenCodeProviderInfo[] = data.all.map((p: { - id: string; - name?: string; - models?: Record>; - }) => ({ - id: p.id, - name: typeof p.name === "string" ? p.name : p.id, - connected: connected.has(p.id), - modelCount: Object.keys(p.models ?? {}).length, - })); + const availableProviderModelCounts = new Map(); // Build a set of loaded local model IDs so we can filter out unloaded models // that OpenCode discovers independently from the local provider endpoints. @@ -178,10 +250,12 @@ export async function probeOpenCodeProviderInventory(args: { } for (const provider of data.all) { - if (!connected.has(provider.id)) continue; const isLocal = isLocalProviderFamily(provider.id); const discoveryExists = isLocal && discoveredLocalProviderIds.has(provider.id); const allowedModels = discoveryExists ? loadedLocalModelIds.get(provider.id) : undefined; + // Local runtime catalogs are volatile. Do not surface OpenCode's static + // local-provider catalog; only show models ADE just discovered as loaded. + if (isLocal && !discoveryExists) continue; const models = provider.models ?? {}; for (const model of Object.values(models)) { const modelRecord = model as Record; @@ -189,7 +263,7 @@ export async function probeOpenCodeProviderInventory(args: { if (!mid.length) continue; // For local providers, only include models that are actively loaded. if (discoveryExists && (!allowedModels || !allowedModels.has(mid))) continue; - const variantKeys = extractVariantKeys(modelRecord); + const variants = classifyOpenCodeVariants(modelRecord); const displayName = typeof modelRecord.name === "string" && modelRecord.name.trim().length ? modelRecord.name.trim() : undefined; const limit = typeof modelRecord.limit === "object" && modelRecord.limit ? modelRecord.limit as { context?: number; output?: number } @@ -200,7 +274,6 @@ export async function probeOpenCodeProviderInventory(args: { const out = typeof limit?.output === "number" ? Number(limit.output) : undefined; - const modalities = modelRecord.modalities as { input?: string[] } | undefined; descriptors.push( createDynamicOpenCodeModelDescriptor("", { openCodeProviderId: provider.id, @@ -208,30 +281,55 @@ export async function probeOpenCodeProviderInventory(args: { ...(displayName ? { displayName } : {}), ...(Number.isFinite(ctx) && (ctx as number) > 0 ? { contextWindow: ctx as number } : {}), ...(Number.isFinite(out) && (out as number) > 0 ? { maxOutputTokens: out as number } : {}), - ...(variantKeys.length ? { reasoningTiers: variantKeys } : {}), - capabilities: { - tools: modelRecord.tool_call !== false, - vision: Boolean(modalities?.input?.includes("image")), - reasoning: modelRecord.reasoning !== false, - streaming: true, - }, + ...(variants.reasoningTiers.length ? { reasoningTiers: variants.reasoningTiers } : {}), + ...(variants.serviceTiers.length ? { serviceTiers: variants.serviceTiers } : {}), + capabilities: readOpenCodeModelCapabilities(modelRecord), }), ); + if (connected.has(provider.id)) { + availableProviderModelCounts.set( + provider.id, + (availableProviderModelCounts.get(provider.id) ?? 0) + 1, + ); + } } } replaceDynamicOpenCodeModelDescriptors(descriptors); - const modelIds = [...descriptors.map((d) => d.id)].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); + const catalogModelIds = [...descriptors.map((d) => d.id)].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); + const modelIds = descriptors + .filter((d) => d.openCodeProviderId ? connected.has(d.openCodeProviderId) : true) + .map((d) => d.id) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); + const catalogCounts = new Map(); + for (const descriptor of descriptors) { + const providerId = descriptor.openCodeProviderId ?? "opencode"; + catalogCounts.set(providerId, (catalogCounts.get(providerId) ?? 0) + 1); + } + const providerInfos: OpenCodeProviderInfo[] = data.all.map((p: { + id: string; + name?: string; + models?: Record>; + }) => ({ + id: p.id, + name: typeof p.name === "string" ? p.name : p.id, + connected: connected.has(p.id), + modelCount: isLocalProviderFamily(p.id) + ? catalogCounts.get(p.id) ?? 0 + : Object.keys(p.models ?? {}).length, + availableModelCount: availableProviderModelCounts.get(p.id) ?? 0, + })); inventoryCache = { cachedAt: Date.now(), projectRoot: args.projectRoot, configFingerprint: fp, passiveConfigFingerprint: passiveFp, + catalogModelIds, modelIds, providers: providerInfos, error: null, }; - return { modelIds, providers: providerInfos, error: null, descriptors }; + return { modelIds, catalogModelIds, providers: providerInfos, error: null, descriptors }; } finally { lease.release("handle_close"); } @@ -244,11 +342,12 @@ export async function probeOpenCodeProviderInventory(args: { projectRoot: args.projectRoot, configFingerprint: fp, passiveConfigFingerprint: passiveFp, + catalogModelIds: [], modelIds: [], providers: [], error: message, }; - return { modelIds: [], providers: [], error: message, descriptors: [] }; + return { modelIds: [], catalogModelIds: [], providers: [], error: message, descriptors: [] }; } finally { probeInFlightMap.delete(probeKey); } @@ -262,10 +361,15 @@ export async function probeOpenCodeProviderInventory(args: { export function peekOpenCodeInventoryCache(args: { projectRoot: string; projectConfig: ProjectConfigFile | EffectiveProjectConfig; -}): { modelIds: string[]; providers: OpenCodeProviderInfo[]; error: string | null } | null { +}): { modelIds: string[]; catalogModelIds: string[]; providers: OpenCodeProviderInfo[]; error: string | null } | null { const fp = fingerprintOpenCodeConfig(args.projectConfig); if (!inventoryCache) return null; if (inventoryCache.projectRoot !== args.projectRoot) return null; if (inventoryCache.passiveConfigFingerprint !== fp && inventoryCache.configFingerprint !== fp) return null; - return { modelIds: inventoryCache.modelIds, providers: inventoryCache.providers, error: inventoryCache.error }; + return { + modelIds: inventoryCache.modelIds, + catalogModelIds: inventoryCache.catalogModelIds, + providers: inventoryCache.providers, + error: inventoryCache.error, + }; } diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index 21f78ef40..0306f3cce 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -616,7 +616,7 @@ describe("createSyncRemoteCommandService", () => { expect(descriptors).toHaveLength(actions.length); for (const desc of descriptors) { expect(desc).toHaveProperty("action"); - expect(desc.scope).toBe("project"); + expect(["project", "runtime"]).toContain(desc.scope); expect(desc).toHaveProperty("policy"); expect(desc.policy).toHaveProperty("viewerAllowed"); } diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 3ddbb965d..48427074a 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -80,6 +80,8 @@ import type { AgentChatHandoffResult, AgentChatInterruptArgs, AgentChatListArgs, + AgentChatModelCatalog, + AgentChatModelCatalogArgs, AgentChatModelInfo, AgentChatModelsArgs, AgentChatParallelLaunchState, @@ -1487,6 +1489,7 @@ declare global { approve: (args: AgentChatApproveArgs) => Promise; respondToInput: (args: AgentChatRespondToInputArgs) => Promise; models: (args: AgentChatModelsArgs) => Promise; + modelCatalog: (args?: AgentChatModelCatalogArgs) => Promise; archive: (args: AgentChatArchiveArgs) => Promise; unarchive: (args: AgentChatArchiveArgs) => Promise; delete: (args: AgentChatDeleteArgs) => Promise; diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 85048d262..927625af1 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -480,6 +480,59 @@ describe("preload OAuth bridge", () => { expect(invoke).toHaveBeenCalledWith(IPC.lanesList, {}); }); + it("falls back to in-process IPC when the local runtime does not expose a new action yet", async () => { + const binding = { + kind: "local", + key: "local:/repo", + rootPath: "/repo", + displayName: "Project", + }; + const catalog = { + generatedAt: "2026-05-18T18:00:00.000Z", + stale: false, + availableModelIds: [], + models: [], + providers: [], + groups: [], + }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: { rootPath: "/repo", displayName: "Project" }, binding }; + } + if (channel === IPC.localRuntimeCallAction) { + throw new Error("Action 'chat.modelCatalog' is not callable."); + } + if (channel === IPC.agentChatModelCatalog) return catalog; + throw new Error(`unexpected IPC: ${channel}`); + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.agentChat.modelCatalog({ mode: "cached" })).resolves.toEqual(catalog); + + expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + request: { domain: "chat", action: "modelCatalog", args: { mode: "cached" } }, + }); + expect(invoke).toHaveBeenCalledWith(IPC.agentChatModelCatalog, { mode: "cached" }); + }); + it("uses in-process IPC for local PR tab reads instead of waiting on the runtime daemon", async () => { const binding = { kind: "local", diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index b81f02f2e..2499cebca 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -289,6 +289,8 @@ import type { AgentChatHandoffResult, AgentChatInterruptArgs, AgentChatListArgs, + AgentChatModelCatalog, + AgentChatModelCatalogArgs, AgentChatModelInfo, AgentChatModelsArgs, AgentChatParallelLaunchState, @@ -1071,6 +1073,11 @@ const allowLocalRuntimeFallback = process.env.ADE_PACKAGE_CHANNEL === "alpha" ); +function isLocalRuntimeActionNotCallableError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /Action '[^']+\.[^']+' is not callable/i.test(message); +} + function isSafeLocalRuntimeFallbackError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); return ( @@ -1078,6 +1085,7 @@ function isSafeLocalRuntimeFallbackError(error: unknown): boolean { /Local runtime daemon is not available/i.test(message) || /ADE service connection (?:closed|failed)/i.test(message) || /IPC handler for 'ade\.localRuntime\.(?:callAction|callSync|streamEvents)' timed out/i.test(message) || + isLocalRuntimeActionNotCallableError(error) || /Timed out waiting for remote ADE service method /i.test(message) || /Timed out connecting to ADE service socket/i.test(message) || /Unsupported database value/i.test(message) || @@ -1178,10 +1186,12 @@ async function callLocalProjectActionIfBound( try { const response = (await ipcRenderer.invoke(IPC.localRuntimeCallAction, { request: { domain, action, ...request }, - })) as RemoteRuntimeActionResult; + })) as RemoteRuntimeActionResult; return { handled: true, result: response.result as T }; } catch (error) { - if (!allowLocalRuntimeFallback || !isSafeLocalRuntimeFallbackError(error)) { + const canUseFallback = + allowLocalRuntimeFallback || isLocalRuntimeActionNotCallableError(error); + if (!canUseFallback || !isSafeLocalRuntimeFallbackError(error)) { throw error; } console.warn( @@ -5235,6 +5245,16 @@ contextBridge.exposeInMainWorld("ade", { ? runtime.result : ipcRenderer.invoke(IPC.agentChatModels, args); }, + modelCatalog: async ( + args?: AgentChatModelCatalogArgs, + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound< + AgentChatModelCatalog + >("chat", "modelCatalog", { args: args ?? {} }); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.agentChatModelCatalog, args ?? {}); + }, archive: async (args: AgentChatArchiveArgs): Promise => { agentChatSummaryCache.clear(); const runtime = await callProjectRuntimeActionIfBound( diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 2da148789..225833f4a 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -4475,6 +4475,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { approve: resolvedArg(undefined), respondToInput: resolvedArg(undefined), models: resolvedArg([]), + modelCatalog: resolvedArg({ groups: [], fetchedAt: new Date(0).toISOString() }), archive: resolvedArg(undefined), unarchive: resolvedArg(undefined), delete: resolvedArg(undefined), diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index f8d3e58f9..b484f21e3 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -36,6 +36,7 @@ import { import { getModelById, modelSupportsFastMode } from "../../../shared/modelRegistry"; import { cn } from "../ui/cn"; import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; +import { resolveModelDescriptorWithRuntimeCatalog } from "../shared/ModelPicker/modelCatalog"; import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; import { getPermissionOptions, safetyColors } from "../shared/permissionOptions"; import { CodexTokenInline } from "./codex/CodexTokenInline"; @@ -663,7 +664,7 @@ function CodexFastModeToggle({ @@ -785,7 +786,6 @@ export function AgentChatComposer({ hideNativeControls = false, messagePlaceholder, onModelChange, - onModelCatalogOpen, onReasoningEffortChange, onCodexFastModeChange, onDraftChange, @@ -905,7 +905,6 @@ export function AgentChatComposer({ hideNativeControls?: boolean; messagePlaceholder?: string; onModelChange: (modelId: string) => void; - onModelCatalogOpen?: () => void; onReasoningEffortChange: (reasoningEffort: string | null) => void; onCodexFastModeChange?: (enabled: boolean) => void; onDraftChange: (value: string) => void; @@ -1773,7 +1772,9 @@ export function AgentChatComposer({ parallelChatMode && parallelConfiguringIndex != null ? (parallelModelSlots[parallelConfiguringIndex]?.modelId ?? "") : (modelId ?? ""); - const fastModeSupported = sp === "codex" && modelSupportsFastMode(getModelById(fastModeModelId)); + const fastModeSupported = modelSupportsFastMode( + resolveModelDescriptorWithRuntimeCatalog(fastModeModelId) ?? getModelById(fastModeModelId), + ); const fastModeActive = parallelChatMode && parallelConfiguringIndex != null ? parallelModelSlots[parallelConfiguringIndex]?.codexFastMode === true @@ -2402,7 +2403,7 @@ export function AgentChatComposer({ if (parallelChatMode) return false; const id = modelId?.trim(); if (!id) return false; - return (getModelById(id)?.reasoningTiers?.length ?? 0) > 0; + return (resolveModelDescriptorWithRuntimeCatalog(id)?.reasoningTiers?.length ?? 0) > 0; }, [parallelChatMode, modelId]); const composerToolbarGridMode = useMemo<"flex" | "grid2" | "grid3">(() => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx index b705c1168..6b4d28308 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -45,6 +45,38 @@ vi.mock("./ChatAppControlPanel", () => { }; }); +vi.mock("@lobehub/icons", () => { + const brand = () => { + const Component = () => null; + Object.assign(Component, { + Avatar: () => null, + Color: () => null, + Combine: () => null, + Text: () => null, + colorPrimary: "#888", + title: "stub", + }); + return Component; + }; + return { + Anthropic: brand(), + Claude: brand(), + Codex: brand(), + Cursor: brand(), + Gemini: brand(), + Google: brand(), + Grok: brand(), + Groq: brand(), + Kimi: brand(), + LmStudio: brand(), + Ollama: brand(), + OpenAI: brand(), + OpenCode: brand(), + OpenRouter: brand(), + XAI: brand(), + }; +}); + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -748,9 +780,10 @@ describe("AgentChatPane submit recovery", () => { availableModelIdsOverride: ["anthropic/claude-sonnet-4-6"], }); - const modelTrigger = await screen.findByRole("button", { name: "Select model" }); + const modelTrigger = await screen.findByRole("button", { name: /^Select model/ }); + fireEvent.pointerDown(modelTrigger, { button: 0 }); fireEvent.click(modelTrigger); - fireEvent.click(await screen.findByRole("button", { name: /^Claude$/i })); + fireEvent.click(await screen.findByRole("tab", { name: /^Anthropic$/i })); await clickEnabledModelOption(/Claude Sonnet 4\.6/i); await waitFor(() => { @@ -1535,9 +1568,10 @@ describe("AgentChatPane submit recovery", () => { renderPane(session); - fireEvent.change(await screen.findByLabelText("Reasoning effort"), { - target: { value: "high" }, - }); + const reasoningTrigger = await screen.findByLabelText("Reasoning effort"); + fireEvent.pointerDown(reasoningTrigger, { button: 0 }); + fireEvent.click(reasoningTrigger); + fireEvent.click(await screen.findByRole("radio", { name: /^High/i })); await waitFor(() => { expect(updateSession).toHaveBeenCalledWith({ @@ -1759,14 +1793,15 @@ describe("AgentChatPane submit recovery", () => { renderPane(session); - const trigger = await screen.findByRole("button", { name: "Select model" }); + const trigger = await screen.findByRole("button", { name: /^Select model/ }); const currentLabel = getModelById(session.modelId ?? "")?.displayName ?? session.modelId ?? ""; const nextLabel = getModelById("anthropic/claude-sonnet-4-6")?.displayName ?? "Claude Sonnet 4.6"; const nextLabelPattern = new RegExp(escapeRegExp(nextLabel), "i"); expect(trigger.textContent ?? "").toContain(currentLabel); + fireEvent.pointerDown(trigger, { button: 0 }); fireEvent.click(trigger); - fireEvent.click(await screen.findByRole("button", { name: /^Claude$/i })); + fireEvent.click(await screen.findByRole("tab", { name: /^Anthropic$/i })); await clickEnabledModelOption(nextLabelPattern); await waitFor(() => { @@ -1775,7 +1810,7 @@ describe("AgentChatPane submit recovery", () => { modelId: "anthropic/claude-sonnet-4-6", })); }); - expect(screen.getByRole("button", { name: "Select model" }).textContent ?? "").toContain(currentLabel); + expect(screen.getByRole("button", { name: /^Select model/ }).textContent ?? "").toContain(currentLabel); expect(warmupModel).not.toHaveBeenCalled(); const updatedSession: AgentChatSessionSummary = { @@ -1792,7 +1827,7 @@ describe("AgentChatPane submit recovery", () => { resolveUpdateSession(updatedSession); await waitFor(() => { - expect(screen.getByRole("button", { name: "Select model" }).textContent ?? "").toContain(nextLabel); + expect(screen.getByRole("button", { name: /^Select model/ }).textContent ?? "").toContain(nextLabel); }); await waitFor(() => { expect(warmupModel).toHaveBeenCalledWith({ @@ -1815,14 +1850,15 @@ describe("AgentChatPane submit recovery", () => { renderPane(session); - const trigger = await screen.findByRole("button", { name: "Select model" }); + const trigger = await screen.findByRole("button", { name: /^Select model/ }); const currentLabel = getModelById(session.modelId ?? "")?.displayName ?? session.modelId ?? ""; const nextLabel = getModelById("anthropic/claude-sonnet-4-6")?.displayName ?? "Claude Sonnet 4.6"; const nextLabelPattern = new RegExp(escapeRegExp(nextLabel), "i"); expect(trigger.textContent ?? "").toContain(currentLabel); + fireEvent.pointerDown(trigger, { button: 0 }); fireEvent.click(trigger); - fireEvent.click(await screen.findByRole("button", { name: /^Claude$/i })); + fireEvent.click(await screen.findByRole("tab", { name: /^Anthropic$/i })); await clickEnabledModelOption(nextLabelPattern); await waitFor(() => { @@ -1832,7 +1868,7 @@ describe("AgentChatPane submit recovery", () => { })); }); await waitFor(() => { - expect(screen.getByRole("button", { name: "Select model" }).textContent ?? "").toContain(currentLabel); + expect(screen.getByRole("button", { name: /^Select model/ }).textContent ?? "").toContain(currentLabel); }); expect(warmupModel).not.toHaveBeenCalled(); }); @@ -1995,9 +2031,9 @@ describe("AgentChatPane submit recovery", () => { const handoffMenu = (await screen.findByText("Start a sibling chat on another model")).closest("[data-chat-handoff-menu='true']"); expect(handoffMenu).toBeTruthy(); - fireEvent.click(within(handoffMenu as HTMLElement).getByRole("button", { name: "Select model" })); + fireEvent.click(within(handoffMenu as HTMLElement).getByRole("button", { name: /^Select model/ })); const claudeLabel = getModelById("anthropic/claude-sonnet-4-6")?.displayName ?? "Claude Sonnet 4.6"; - fireEvent.click(await screen.findByRole("button", { name: /^Claude$/i })); + fireEvent.click(await screen.findByRole("tab", { name: /^Anthropic$/i })); await clickEnabledModelOption(new RegExp(escapeRegExp(claudeLabel), "i")); expect(screen.getByText("Fork keeps the complete Claude transcript through the SDK. Brief sends a summary as the first message.")).toBeTruthy(); @@ -2039,9 +2075,9 @@ describe("AgentChatPane submit recovery", () => { const handoffMenu = (await screen.findByText("Start a sibling chat on another model")).closest("[data-chat-handoff-menu='true']"); expect(handoffMenu).toBeTruthy(); - fireEvent.click(within(handoffMenu as HTMLElement).getByRole("button", { name: "Select model" })); + fireEvent.click(within(handoffMenu as HTMLElement).getByRole("button", { name: /^Select model/ })); const claudeLabel = getModelById("anthropic/claude-sonnet-4-6")?.displayName ?? "Claude Sonnet 4.6"; - fireEvent.click(await screen.findByRole("button", { name: /^Claude$/i })); + fireEvent.click(await screen.findByRole("tab", { name: /^Anthropic$/i })); await clickEnabledModelOption(new RegExp(escapeRegExp(claudeLabel), "i")); fireEvent.click(await screen.findByRole("button", { name: "Fork full history" })); @@ -2068,11 +2104,12 @@ describe("AgentChatPane submit recovery", () => { , ); - const trigger = await screen.findByRole("button", { name: "Select model" }); + const trigger = await screen.findByRole("button", { name: /^Select model/ }); const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.pointerDown(trigger, { button: 0 }); fireEvent.click(trigger); - fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); + fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); const textbox = await screen.findByRole("textbox"); @@ -2108,11 +2145,12 @@ describe("AgentChatPane submit recovery", () => { , ); - const trigger = await screen.findByRole("button", { name: "Select model" }); + const trigger = await screen.findByRole("button", { name: /^Select model/ }); const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; - fireEvent.click(trigger); - fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); + fireEvent.pointerDown(trigger, { button: 0 }); + fireEvent.click(trigger); + fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); const textbox = await screen.findByRole("textbox"); @@ -2149,10 +2187,11 @@ describe("AgentChatPane submit recovery", () => { renderAutoCreateDraftPane({ onSessionCreated }); - const modelTrigger = await screen.findByRole("button", { name: "Select model" }); + const modelTrigger = await screen.findByRole("button", { name: /^Select model/ }); const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.pointerDown(modelTrigger, { button: 0 }); fireEvent.click(modelTrigger); - fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); + fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); @@ -2203,10 +2242,11 @@ describe("AgentChatPane submit recovery", () => { renderAutoCreateDraftPane({ onSessionCreated }); - const modelTrigger = await screen.findByRole("button", { name: "Select model" }); + const modelTrigger = await screen.findByRole("button", { name: /^Select model/ }); const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.pointerDown(modelTrigger, { button: 0 }); fireEvent.click(modelTrigger); - fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); + fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); @@ -2250,10 +2290,11 @@ describe("AgentChatPane submit recovery", () => { renderAutoCreateDraftPane({ onSessionCreated }); - const modelTrigger = await screen.findByRole("button", { name: "Select model" }); + const modelTrigger = await screen.findByRole("button", { name: /^Select model/ }); const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.pointerDown(modelTrigger, { button: 0 }); fireEvent.click(modelTrigger); - fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); + fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); @@ -2292,10 +2333,11 @@ describe("AgentChatPane submit recovery", () => { renderAutoCreateDraftPane({ onSessionCreated }); - const modelTrigger = await screen.findByRole("button", { name: "Select model" }); + const modelTrigger = await screen.findByRole("button", { name: /^Select model/ }); const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.pointerDown(modelTrigger, { button: 0 }); fireEvent.click(modelTrigger); - fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); + fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); @@ -2322,10 +2364,11 @@ describe("AgentChatPane submit recovery", () => { renderAutoCreateDraftPane(); - const modelTrigger = await screen.findByRole("button", { name: "Select model" }); + const modelTrigger = await screen.findByRole("button", { name: /^Select model/ }); const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.pointerDown(modelTrigger, { button: 0 }); fireEvent.click(modelTrigger); - fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); + fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); @@ -2368,11 +2411,12 @@ describe("AgentChatPane submit recovery", () => { , ); - const trigger = await screen.findByRole("button", { name: "Select model" }); + const trigger = await screen.findByRole("button", { name: /^Select model/ }); const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.pointerDown(trigger, { button: 0 }); fireEvent.click(trigger); - fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); + fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); const textbox = await screen.findByRole("textbox"); @@ -2439,11 +2483,12 @@ describe("AgentChatPane submit recovery", () => { , ); - const trigger = await screen.findByRole("button", { name: "Select model" }); + const trigger = await screen.findByRole("button", { name: /^Select model/ }); const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.pointerDown(trigger, { button: 0 }); fireEvent.click(trigger); - fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); + fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); const textbox = await screen.findByRole("textbox"); @@ -2924,19 +2969,21 @@ describe("AgentChatPane submit recovery", () => { ], }); - const baseModelTrigger = await screen.findByRole("button", { name: "Select model" }); + const baseModelTrigger = await screen.findByRole("button", { name: /^Select model/ }); const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.pointerDown(baseModelTrigger, { button: 0 }); fireEvent.click(baseModelTrigger); - fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); + fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); fireEvent.click(await screen.findByRole("button", { name: /Parallel models/i })); fireEvent.click(screen.getAllByRole("button", { name: "Configure" })[1]!); - const modelTrigger = await screen.findByRole("button", { name: "Select model" }); + const modelTrigger = await screen.findByRole("button", { name: /^Select model/ }); const claudeLabel = getModelById("anthropic/claude-sonnet-4-6")?.displayName ?? "Claude Sonnet 4.6"; + fireEvent.pointerDown(modelTrigger, { button: 0 }); fireEvent.click(modelTrigger); - fireEvent.click(await screen.findByRole("button", { name: /^Claude$/i })); + fireEvent.click(await screen.findByRole("tab", { name: /^Anthropic$/i })); await clickEnabledModelOption(new RegExp(escapeRegExp(claudeLabel), "i")); fireEvent.change(screen.getByRole("textbox"), { target: { value: "Fix the login bug" } }); @@ -3090,16 +3137,17 @@ describe("AgentChatPane submit recovery", () => { ], }); - const baseModelTrigger = await screen.findByRole("button", { name: "Select model" }); + const baseModelTrigger = await screen.findByRole("button", { name: /^Select model/ }); const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.pointerDown(baseModelTrigger, { button: 0 }); fireEvent.click(baseModelTrigger); - fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); + fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); fireEvent.click(await screen.findByRole("button", { name: /Parallel models/i })); fireEvent.click(screen.getAllByRole("button", { name: "Configure" })[1]!); - fireEvent.click(await screen.findByRole("button", { name: "Select model" })); - fireEvent.click(await screen.findByRole("button", { name: /^Claude$/i })); + fireEvent.click(await screen.findByRole("button", { name: /^Select model/ })); + fireEvent.click(await screen.findByRole("tab", { name: /^Anthropic$/i })); await clickEnabledModelOption(/Claude Sonnet 4\.6/i); fireEvent.change(screen.getByRole("textbox"), { target: { value: "Fix the login bug" } }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 9489b8dac..a9f4e6b60 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -71,6 +71,7 @@ import { filterChatModelIdsForSession } from "../../../shared/chatModelSwitching import { CURSOR_AVAILABLE_MODE_IDS } from "../../../shared/cursorModes"; import { cn } from "../ui/cn"; import { AgentChatComposer, type ParallelComposerControlSlot } from "./AgentChatComposer"; +import { resolveModelDescriptorWithRuntimeCatalog } from "../shared/ModelPicker/modelCatalog"; import { AgentChatMessageList } from "./AgentChatMessageList"; import { ChatStatusGlyph } from "./chatStatusVisuals"; import { isChatToolType } from "../../lib/sessions"; @@ -1351,11 +1352,6 @@ function orderAvailableModelIds(ids: Iterable): string[] { return [...ordered, ...extra]; } -function isCursorModelId(id: string): boolean { - return id.startsWith("cursor/") - || getModelById(id)?.family === "cursor"; -} - function completionBadgeClass(status: NonNullable["status"]): string { switch (status) { case "completed": return "border-emerald-400/20 bg-emerald-400/[0.08] text-emerald-300"; @@ -1578,12 +1574,7 @@ export function AgentChatPane({ const [availableModelIds, setAvailableModelIds] = useState(() => seedAiStatus ? deriveConfiguredModelIds(seedAiStatus, { includeDroid: true }) : [], ); - const availableModelIdsRef = useRef(availableModelIds); const availableModelsRefreshSeqRef = useRef(0); - const cursorInventoryRefreshSeqRef = useRef(0); - useEffect(() => { - availableModelIdsRef.current = availableModelIds; - }, [availableModelIds]); const [claudePermissionMode, setClaudePermissionMode] = useState(initialNativeControls.claudePermissionMode); const [codexApprovalPolicy, setCodexApprovalPolicy] = useState(initialNativeControls.codexApprovalPolicy); const [codexSandbox, setCodexSandbox] = useState(initialNativeControls.codexSandbox); @@ -2267,7 +2258,7 @@ export function AgentChatPane({ return ids; }, [pendingInputsBySession, selectedSessionId]); const pendingSteers = selectedSessionId ? (pendingSteersBySession[selectedSessionId] ?? []) : []; - const selectedModelDesc = getModelById(modelId); + const selectedModelDesc = resolveModelDescriptorWithRuntimeCatalog(modelId) ?? getModelById(modelId); const reasoningTiers = selectedModelDesc?.reasoningTiers ?? []; const localRuntimeState = useMemo(() => { const provider = selectedModelDesc?.authTypes.includes("local") @@ -2820,46 +2811,6 @@ export function AgentChatPane({ } }, [modelId, projectRoot, selectedSession?.provider, sessionProvider]); - const refreshCursorModelInventory = useCallback(async () => { - const cursorRefreshSeq = ++cursorInventoryRefreshSeqRef.current; - const status = aiStatus; - const cursorExplicitlyUnavailable = - status != null - && status.availableProviders?.cursor !== true - && status.providerConnections?.cursor?.runtimeAvailable !== true; - if (cursorExplicitlyUnavailable) return; - if (availableModelIdsRef.current.some(isCursorModelId)) return; - const refreshSeq = availableModelsRefreshSeqRef.current; - let cursorModels: Awaited>; - try { - cursorModels = await getAgentChatModelsCached({ - projectRoot, - provider: "cursor", - activateRuntime: true, - }); - } catch { - return; - } - if ( - availableModelsRefreshSeqRef.current !== refreshSeq - || cursorInventoryRefreshSeqRef.current !== cursorRefreshSeq - ) { - return; - } - if (!cursorModels.length) { - setAvailableModelIds((prev) => prev.filter((id) => !isCursorModelId(id))); - return; - } - setAvailableModelIds((prev) => { - const merged = new Set(prev); - for (const model of cursorModels) { - const resolved = resolveCliRegistryModelId("cursor", model.id); - if (resolved) merged.add(resolved); - } - return orderAvailableModelIds(merged); - }); - }, [aiStatus, projectRoot]); - const touchSession = useCallback((sessionId: string | null | undefined, touchedAt = new Date().toISOString()) => { if (!sessionId) return; const previousTouch = localTouchBySessionRef.current.get(sessionId); @@ -4166,7 +4117,7 @@ export function AgentChatPane({ }, []); const buildModelSelectionSnapshot = useCallback((nextModelId: string) => { const previousDesc = prevModelDescRef.current; - const nextDesc = getModelById(nextModelId); + const nextDesc = resolveModelDescriptorWithRuntimeCatalog(nextModelId) ?? getModelById(nextModelId); const nextPermissionDesc = getModelDescriptorForPermissionMode(nextModelId); const nextProvider = resolveChatRuntimeProvider(nextDesc); const nextModel = nextProvider === "opencode" ? nextModelId : runtimeFacingModelId(nextDesc, nextModelId); @@ -4228,7 +4179,7 @@ export function AgentChatPane({ targetLaneId: string, options: { select?: boolean; notify?: boolean; notifyOptions?: AgentChatSessionCreatedOptions } = {}, ): Promise => { - const desc = getModelById(modelId); + const desc = resolveModelDescriptorWithRuntimeCatalog(modelId) ?? getModelById(modelId); const permissionDesc = getModelDescriptorForPermissionMode(modelId); const provider = resolveChatRuntimeProvider(desc); const model = provider === "opencode" ? modelId : runtimeFacingModelId(desc, modelId); @@ -4252,7 +4203,7 @@ export function AgentChatPane({ modelId, sessionProfile, reasoningEffort, - ...(provider === "codex" ? { codexFastMode } : {}), + ...(modelSupportsFastMode(desc) ? { codexFastMode } : {}), ...nativeControlPayload, }); loadedHistoryRef.current.delete(created.id); @@ -4788,7 +4739,7 @@ export function AgentChatPane({ modelId: slot.modelId, sessionProfile: resolveChatSessionProfile(), reasoningEffort: slot.reasoningEffort, - ...(provider === "codex" ? { codexFastMode: slot.codexFastMode } : {}), + ...(modelSupportsFastMode(desc) ? { codexFastMode: slot.codexFastMode } : {}), ...buildNativeControlPayloadForSlot(slot, provider), }); sessionByLane.set(childLane.id, created.id); @@ -5144,13 +5095,13 @@ export function AgentChatPane({ || shouldPromoteLightSession )) { setOptimisticOutgoingMessageSynced({ sessionId, envelope: optimisticEnvelope(sessionId) }); - const desc = getModelById(modelId); + const desc = resolveModelDescriptorWithRuntimeCatalog(modelId) ?? getModelById(modelId); const provider = resolveChatRuntimeProvider(desc); await window.ade.agentChat.updateSession({ sessionId, modelId, reasoningEffort, - ...(provider === "codex" ? { codexFastMode } : {}), + ...(modelSupportsFastMode(desc) ? { codexFastMode } : {}), ...buildNativeControlPayload(provider), }); void refreshSessions().catch(() => {}); @@ -6403,7 +6354,6 @@ export function AgentChatPane({ hideNativeControls={hideNativeControls} messagePlaceholder={effectiveMessagePlaceholder} onExecutionModeChange={setExecutionMode} - onModelCatalogOpen={refreshCursorModelInventory} onInteractionModeChange={(value) => { void updateNativeControls({ interactionMode: value }); }} onClaudeModeChange={handleClaudeModeChange} onClaudePermissionModeChange={(value) => { void updateNativeControls({ claudePermissionMode: value }); }} @@ -6468,7 +6418,7 @@ export function AgentChatPane({ sessionId: selectedSessionId, modelId: nextModelId, reasoningEffort: snapshot.nextReasoningEffort, - ...(snapshot.nextProvider === "codex" ? { codexFastMode } : {}), + ...(modelSupportsFastMode(snapshot.nextDesc) ? { codexFastMode } : {}), ...nextNativeControlPayload, }).then((updatedSession) => { applyModelSelectionSnapshot(snapshot); @@ -6674,7 +6624,7 @@ export function AgentChatPane({ }); }} onParallelSlotModelChange={(index, nextModelId) => { - const desc = getModelById(nextModelId); + const desc = resolveModelDescriptorWithRuntimeCatalog(nextModelId) ?? getModelById(nextModelId); const tiers = desc?.reasoningTiers ?? []; const preferred = readLastUsedReasoningEffort({ laneId, modelId: nextModelId }); const nextEffort = selectReasoningEffort({ tiers, preferred }); diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx index f8a0a55b7..99f74ed27 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx @@ -29,6 +29,8 @@ vi.mock("@lobehub/icons", () => { Grok: brand(), Groq: brand(), Kimi: brand(), + LmStudio: brand(), + Ollama: brand(), OpenAI: brand(), OpenCode: brand(), OpenRouter: brand(), diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx index 1152ea2af..9da41b734 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx @@ -1,7 +1,7 @@ /* @vitest-environment jsdom */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ModelDescriptor } from "../../../../shared/modelRegistry"; @@ -129,6 +129,8 @@ vi.mock("./modelOrdering", () => ({ })); import { ModelPicker } from "./ModelPicker"; +import { resetModelPickerRuntimeCatalogForTests } from "./runtimeCatalogCache"; +import { resetRuntimeCatalogDescriptorCacheForTests } from "./modelCatalog"; const SONNET: ModelDescriptor = { id: "anthropic/claude-sonnet-4-6", @@ -181,6 +183,24 @@ const GPT: ModelDescriptor = { isCliWrapped: true, }; +const OPENCODE_MODEL: ModelDescriptor = { + id: "opencode/anthropic/claude-sonnet-4-6", + shortId: "claude-sonnet-4-6", + displayName: "Claude Sonnet 4.6 via OpenCode", + family: "opencode", + authTypes: ["api-key"], + contextWindow: 200_000, + maxOutputTokens: 32_000, + capabilities: { tools: true, vision: true, reasoning: true, streaming: true }, + reasoningTiers: ["low", "medium", "high"], + color: "#D97706", + providerRoute: "opencode", + providerModelId: "anthropic/claude-sonnet-4-6", + openCodeProviderId: "anthropic", + openCodeModelId: "claude-sonnet-4-6", + isCliWrapped: false, +}; + const MODELS: ModelDescriptor[] = [SONNET, OPUS, GPT]; beforeEach(() => { @@ -190,6 +210,13 @@ beforeEach(() => { for (const key of Object.keys(reasoningByFamilyStore)) delete reasoningByFamilyStore[key]; providerAuthStatusInternal = {}; opencodeBinaryInstalledInternal = true; + resetModelPickerRuntimeCatalogForTests(); + resetRuntimeCatalogDescriptorCacheForTests(); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: undefined, + }); }); afterEach(() => { @@ -399,6 +426,7 @@ describe("ModelPicker", () => { expect(banner.getAttribute("data-provider-family")).toBe("anthropic"); await user.click(banner); expect(onOpenSignIn).toHaveBeenCalledOnce(); + expect(screen.getByRole("button", { name: /Select model/i }).getAttribute("aria-expanded")).toBe("false"); }); it("does not render the Set up banner when the active rail is authed", async () => { @@ -446,6 +474,174 @@ describe("ModelPicker", () => { }); describe("OpenCode binary gating", () => { + it("refreshes the initially selected OpenCode rail on first open", async () => { + const user = userEvent.setup(); + const modelCatalog = vi.fn(async () => ({ + groups: [], + fetchedAt: "2026-05-18T00:00:00.000Z", + stale: false, + })); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + agentChat: { + modelCatalog, + }, + }, + }); + + renderPicker({ value: OPENCODE_MODEL.id, models: [OPENCODE_MODEL] }); + await user.click(screen.getByRole("button", { name: /Select model/i })); + + await waitFor(() => { + expect(modelCatalog).toHaveBeenCalledWith({ mode: "refresh-stale", refreshProvider: "opencode" }); + }); + }); + + it("shows a runtime loading empty state instead of setup while OpenCode is refreshing", async () => { + const user = userEvent.setup(); + providerAuthStatusInternal = { opencode: "unauthed" }; + let resolveRefresh: ((value: { groups: []; fetchedAt: string; stale: false }) => void) | null = null; + const refreshPromise = new Promise<{ groups: []; fetchedAt: string; stale: false }>((resolve) => { + resolveRefresh = resolve; + }); + const modelCatalog = vi.fn((args: { mode?: string }) => { + if (args.mode === "refresh-stale") return refreshPromise; + return Promise.resolve({ groups: [], fetchedAt: "2026-05-18T00:00:00.000Z", stale: false }); + }); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + agentChat: { + modelCatalog, + }, + }, + }); + + renderPicker({ onOpenSignIn: vi.fn() }); + await user.click(screen.getByRole("button", { name: /Select model/i })); + await user.click(document.querySelector('[data-rail-selection="provider:opencode"]') as HTMLButtonElement); + + await waitFor(() => { + expect(document.querySelector('[data-empty-state-mode="runtime-loading"][data-refresh-provider="opencode"]')).toBeTruthy(); + }); + expect(document.querySelector('[data-model-picker-setup-banner="true"]')).toBeNull(); + + await act(async () => { + resolveRefresh?.({ groups: [], fetchedAt: "2026-05-18T00:00:01.000Z", stale: false }); + await refreshPromise; + }); + }); + + it("returns a stale runtime catalog immediately and forces refresh in the background when a runtime rail is selected", async () => { + const user = userEvent.setup(); + const staleCatalog = { groups: [], fetchedAt: "2026-05-18T00:00:00.000Z", stale: true }; + const freshCatalog = { groups: [], fetchedAt: "2026-05-18T00:00:01.000Z" }; + const modelCatalog = vi.fn(async (args: { mode?: string }) => { + if (args.mode === "refresh-stale") return staleCatalog; + return freshCatalog; + }); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + agentChat: { + modelCatalog, + }, + }, + }); + + renderPicker(); + await user.click(screen.getByRole("button", { name: /Select model/i })); + const opencodeRail = document.querySelector( + '[data-rail-selection="provider:opencode"]', + ) as HTMLButtonElement; + await user.click(opencodeRail); + + await waitFor(() => { + expect(modelCatalog).toHaveBeenCalledWith({ mode: "refresh-stale", refreshProvider: "opencode" }); + }); + await waitFor(() => { + expect(modelCatalog).toHaveBeenCalledWith({ mode: "force", refreshProvider: "opencode" }); + }); + + modelCatalog.mockClear(); + await user.click(opencodeRail); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(modelCatalog).not.toHaveBeenCalled(); + }); + + it("renders a fresh shared runtime catalog immediately on a later picker mount", async () => { + const user = userEvent.setup(); + const freshCatalog = { + groups: [ + { + key: "opencode" as const, + displayName: "OpenCode", + providers: [ + { + key: "anthropic", + displayName: "Anthropic", + badgeColor: "#D97706", + modelCount: 1, + subsections: [ + { + key: "anthropic", + label: "Anthropic", + models: [ + { + id: OPENCODE_MODEL.id, + runtimeModelId: "claude-sonnet-4-6", + provider: "opencode" as const, + providerKey: "opencode", + groupKey: "opencode" as const, + displayName: OPENCODE_MODEL.displayName, + isDefault: false, + isAvailable: true, + providerId: "anthropic", + providerName: "Anthropic", + }, + ], + }, + ], + }, + ], + }, + ], + fetchedAt: "2026-05-18T00:00:00.000Z", + stale: false, + }; + const modelCatalog = vi.fn(async () => freshCatalog); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + agentChat: { + modelCatalog, + }, + }, + }); + + const first = renderPicker({ models: undefined }); + await user.click(screen.getByRole("button", { name: /Select model/i })); + await user.click(document.querySelector('[data-rail-selection="provider:opencode"]') as HTMLButtonElement); + await waitFor(() => { + expect(screen.getByText(OPENCODE_MODEL.displayName)).toBeTruthy(); + }); + first.unmount(); + + modelCatalog.mockClear(); + renderPicker({ models: undefined }); + await user.click(screen.getByRole("button", { name: /Select model/i })); + await user.click(document.querySelector('[data-rail-selection="provider:opencode"]') as HTMLButtonElement); + + expect(screen.getByText(OPENCODE_MODEL.displayName)).toBeTruthy(); + expect(modelCatalog).not.toHaveBeenCalled(); + }); + it("shows the same Install OpenCode copy for opencode, ollama, and lmstudio panes when the binary is missing", async () => { const user = userEvent.setup(); opencodeBinaryInstalledInternal = false; @@ -503,5 +699,31 @@ describe("ModelPicker", () => { await user.click(opencodeRail); expect(document.querySelector('[data-model-picker-setup-banner="true"]')).toBeNull(); }); + + it("closes before opening Settings from the OpenCode-required empty state", async () => { + const user = userEvent.setup(); + opencodeBinaryInstalledInternal = false; + const onOpenSignIn = vi.fn(); + render( + , + ); + + const trigger = screen.getByRole("button", { name: /Select model/i }); + await user.click(trigger); + const opencodeRail = document.querySelector( + '[data-rail-selection="provider:opencode"]', + ) as HTMLButtonElement; + await user.click(opencodeRail); + await user.click(screen.getByRole("button", { name: /Open Settings/i })); + + expect(onOpenSignIn).toHaveBeenCalledOnce(); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + }); }); }); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx index 55c22c42e..bab1bf71a 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx @@ -1,9 +1,8 @@ -import { forwardRef, memo, useCallback, useMemo, useState } from "react"; +import { forwardRef, memo, useCallback, useEffect, useMemo, useState } from "react"; import * as Popover from "@radix-ui/react-popover"; import { CaretDown, Lightning } from "@phosphor-icons/react"; import { modelSupportsFastMode, - resolveModelDescriptor, type ModelDescriptor, type ProviderFamily, } from "../../../../shared/modelRegistry"; @@ -11,8 +10,22 @@ import { ModelRowLogo } from "../ProviderLogos"; import { cn } from "../../ui/cn"; import { ModelPickerContent } from "./ModelPickerContent"; import type { AuthStatus } from "./ModelPickerRail"; -import { createUnknownModelPlaceholder, mergeSelectorModels } from "./modelCatalog"; +import { + createUnknownModelPlaceholder, + descriptorsFromAgentChatModelCatalog, + mergeSelectorModels, + resolveModelDescriptorWithRuntimeCatalog, +} from "./modelCatalog"; import { useModelRecents } from "./useModelRecents"; +import type { AgentChatModelCatalog, AgentChatModelCatalogRefreshProvider } from "../../../../shared/types"; +import { + clearRuntimeCatalogRequest, + getRuntimeCatalogRequest, + getSharedRuntimeCatalog, + rememberRuntimeCatalog, + runtimeCatalogProviderIsFresh, + setRuntimeCatalogRequest, +} from "./runtimeCatalogCache"; export type ModelPickerProps = { value: string; @@ -52,12 +65,106 @@ export const ModelPicker = memo(function ModelPicker({ triggerClassName, }: ModelPickerProps) { const [open, setOpen] = useState(false); + const [runtimeCatalog, setRuntimeCatalog] = useState(() => getSharedRuntimeCatalog()); + const [refreshingProvider, setRefreshingProvider] = useState(null); const { recents } = useModelRecents(); + const loadRuntimeCatalog = useCallback(async (args: { + mode: "cached" | "refresh-stale" | "force"; + refreshProvider?: AgentChatModelCatalogRefreshProvider; + }): Promise => { + const shared = getSharedRuntimeCatalog(); + if (args.mode === "cached" && shared) { + setRuntimeCatalog(shared); + return shared; + } + if (args.mode === "refresh-stale" && args.refreshProvider && shared) { + setRuntimeCatalog(shared); + if (runtimeCatalogProviderIsFresh(args.refreshProvider)) { + return { ...shared, stale: false }; + } + } + + const bridge = window.ade?.agentChat?.modelCatalog; + if (typeof bridge !== "function") return null; + const requestKey = `${args.mode}:${args.refreshProvider ?? "all"}`; + const existingRequest = getRuntimeCatalogRequest(requestKey); + if (existingRequest) { + const next = await existingRequest; + if (next) setRuntimeCatalog(next); + return next; + } + + const request = (async () => { + try { + const next = await bridge(args); + const visible = rememberRuntimeCatalog(next, args); + setRuntimeCatalog(visible); + return visible; + } catch { + // Keep the last catalog visible; renderer fallbacks cover older runtimes. + return null; + } + })(); + setRuntimeCatalogRequest(requestKey, request); + void request.finally(() => { + clearRuntimeCatalogRequest(requestKey, request); + }); + return await request; + }, []); + + useEffect(() => { + if (!open) return; + void loadRuntimeCatalog({ mode: "cached" }); + }, [loadRuntimeCatalog, open]); + + const handleProviderRailSelect = useCallback((family: ProviderFamily) => { + const refreshProvider: AgentChatModelCatalogRefreshProvider | null = + family === "opencode" + ? "opencode" + : family === "ollama" + ? "ollama" + : family === "lmstudio" + ? "lmstudio" + : family === "cursor" + ? "cursor" + : family === "factory" + ? "droid" + : null; + if (refreshProvider) { + void (async () => { + const shared = getSharedRuntimeCatalog(); + if (shared) { + setRuntimeCatalog(shared); + if (runtimeCatalogProviderIsFresh(refreshProvider)) return; + } + setRefreshingProvider(refreshProvider); + try { + const immediate = await loadRuntimeCatalog({ mode: "refresh-stale", refreshProvider }); + if (immediate?.stale === true) { + await loadRuntimeCatalog({ mode: "force", refreshProvider }); + } + } finally { + setRefreshingProvider((current) => current === refreshProvider ? null : current); + } + })(); + } + }, [loadRuntimeCatalog]); + + const catalogModels = useMemo( + () => descriptorsFromAgentChatModelCatalog(runtimeCatalog, filter), + [filter, runtimeCatalog], + ); + const modelList = useMemo(() => { if (models && models.length) return models; - return mergeSelectorModels(availableModelIds, value, filter, catalogMode); - }, [models, availableModelIds, value, filter, catalogMode]); + const fallbackModels = mergeSelectorModels(availableModelIds, value, filter, catalogMode); + if (catalogModels.models.length === 0) return fallbackModels; + const merged = new Map(); + for (const model of fallbackModels) merged.set(model.id, model); + for (const model of catalogModels.models) merged.set(model.id, model); + return [...merged.values()]; + }, [models, availableModelIds, value, filter, catalogMode, catalogModels.models]); const effectiveValue = useMemo(() => { if (value && value.length > 0) return value; @@ -72,13 +179,14 @@ export const ModelPicker = memo(function ModelPicker({ const selectedModel = useMemo(() => { if (!effectiveValue) return undefined; - return resolveModelDescriptor(effectiveValue) ?? createUnknownModelPlaceholder(effectiveValue); + return resolveModelDescriptorWithRuntimeCatalog(effectiveValue) ?? createUnknownModelPlaceholder(effectiveValue); }, [effectiveValue]); const availableSet = useMemo(() => { - if (!availableModelIds) return null; - return new Set(availableModelIds.map((id) => id.trim()).filter(Boolean)); - }, [availableModelIds]); + const ids = runtimeCatalog ? catalogModels.availableModelIds : availableModelIds; + if (!ids) return null; + return new Set(ids.map((id) => id.trim()).filter(Boolean)); + }, [availableModelIds, catalogModels.availableModelIds, runtimeCatalog]); const isAvailable = useCallback( (modelId: string): boolean => { @@ -100,6 +208,11 @@ export const ModelPicker = memo(function ModelPicker({ setOpen(false); }, []); + const handleOpenSignIn = useCallback(() => { + setOpen(false); + onOpenSignIn?.(); + }, [onOpenSignIn]); + const triggerFastSupported = typeof fastModeSupported === "boolean" ? fastModeSupported @@ -115,6 +228,10 @@ export const ModelPicker = memo(function ModelPicker({ setOpen(false); return; } + const shared = getSharedRuntimeCatalog(); + if (next && shared) { + setRuntimeCatalog(shared); + } setOpen(next); }} > @@ -149,7 +266,9 @@ export const ModelPicker = memo(function ModelPicker({ {...(providerAuthStatus ? { providerAuthStatus } : {})} onSelect={handleSelect} onRequestClose={handleRequestClose} - {...(onOpenSignIn ? { onOpenSignIn } : {})} + onProviderRailSelect={handleProviderRailSelect} + refreshingProvider={refreshingProvider} + {...(onOpenSignIn ? { onOpenSignIn: handleOpenSignIn } : {})} /> ) : null} diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx index dda3649bf..a086834a7 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx @@ -20,6 +20,7 @@ import { useProviderAuthStatus } from "./useProviderAuthStatus"; import { scoreModelPickerSearch } from "./modelPickerSearch"; import { sortModelItems } from "./modelOrdering"; import { ProviderEmptyState, ProviderSetupBanner } from "./providerEmptyState"; +import type { AgentChatModelCatalogRefreshProvider } from "../../../../shared/types"; const PROVIDER_LABELS: Partial> = { anthropic: "Anthropic", @@ -68,6 +69,28 @@ function modelSubProvider(model: ModelDescriptor): string { return ""; } +function modelSubProviderKey(model: ModelDescriptor): string { + const key = (model as ModelDescriptor & { subProviderKey?: string }).subProviderKey; + if (typeof key === "string" && key.trim().length) return key.trim(); + if (model.providerRoute === "opencode" && model.openCodeProviderId) return model.openCodeProviderId; + return modelSubProvider(model) || "__default__"; +} + +function refreshProviderForFamily(family: ProviderFamily): AgentChatModelCatalogRefreshProvider | null { + if (family === "opencode") return "opencode"; + if (family === "ollama") return "ollama"; + if (family === "lmstudio") return "lmstudio"; + if (family === "cursor") return "cursor"; + if (family === "factory") return "droid"; + return null; +} + +function refreshProviderLabel(provider: AgentChatModelCatalogRefreshProvider): string { + if (provider === "lmstudio") return "LM Studio"; + if (provider === "droid") return "Droid"; + return providerLabel(provider); +} + export type ModelPickerContentProps = { value: string; surfaceKey: string; @@ -76,6 +99,8 @@ export type ModelPickerContentProps = { providerAuthStatus?: Partial>; onSelect: (modelId: string) => void; onRequestClose: () => void; + onProviderRailSelect?: (family: ProviderFamily) => void; + refreshingProvider?: AgentChatModelCatalogRefreshProvider | null; onOpenSignIn?: () => void; }; @@ -87,6 +112,8 @@ export const ModelPickerContent = memo(function ModelPickerContent({ providerAuthStatus, onSelect, onRequestClose, + onProviderRailSelect, + refreshingProvider, onOpenSignIn, }: ModelPickerContentProps) { const [query, setQuery] = useState(""); @@ -227,7 +254,7 @@ export const ModelPickerContent = memo(function ModelPickerContent({ [favoriteSet], ); - const visibleModels = useMemo(() => { + const candidateModels = useMemo(() => { let pool: ModelDescriptor[] = []; if (searchActive) { pool = expandedModels.filter(filterAvailable); @@ -273,25 +300,62 @@ export const ModelPickerContent = memo(function ModelPickerContent({ toSearchItem, ]); - const groupedRows = useMemo(() => { - if (selection === "favorites" || selection === "recents" || searchActive) { - return [{ subProvider: "", models: visibleModels }]; + const activeProviderFamily = useMemo(() => { + if (searchActive) return null; + if (selection === "favorites" || selection === "recents") return null; + return selection.slice("provider:".length) as ProviderFamily; + }, [searchActive, selection]); + + useEffect(() => { + if (!activeProviderFamily) return; + onProviderRailSelect?.(activeProviderFamily); + }, [activeProviderFamily, onProviderRailSelect]); + + const activeRefreshProvider = activeProviderFamily ? refreshProviderForFamily(activeProviderFamily) : null; + const activeProviderRefreshing = activeRefreshProvider != null && refreshingProvider === activeRefreshProvider; + + const providerTabs = useMemo(() => { + if (!activeProviderFamily) return []; + const byKey = new Map(); + for (const model of candidateModels) { + const key = modelSubProviderKey(model); + const label = modelSubProvider(model) || providerLabel(activeProviderFamily); + const existing = byKey.get(key); + if (existing) { + existing.models.push(model); + existing.hasAvailable = existing.hasAvailable || isAvailable(model.id); + } else { + byKey.set(key, { key, label, models: [model], hasAvailable: isAvailable(model.id) }); + } } - const groups = new Map(); - for (const m of visibleModels) { - const key = modelSubProvider(m); - const list = groups.get(key); - if (list) list.push(m); - else groups.set(key, [m]); + return [...byKey.values()]; + }, [activeProviderFamily, candidateModels, isAvailable]); + + const [activeProviderTabKey, setActiveProviderTabKey] = useState(null); + useEffect(() => { + if (providerTabs.length <= 1) { + setActiveProviderTabKey(null); + return; } - const arr = [...groups.entries()].map(([subProvider, modelsInGroup]) => ({ - subProvider, - models: modelsInGroup, - })); - return arr; - }, [selection, searchActive, visibleModels]); + setActiveProviderTabKey((current) => { + if (current && providerTabs.some((tab) => tab.key === current)) return current; + const activeModel = expandedModels.find((model) => model.id === value); + const activeKey = activeModel && activeProviderFamily === activeModel.family + ? modelSubProviderKey(activeModel) + : null; + if (activeKey && providerTabs.some((tab) => tab.key === activeKey)) return activeKey; + return providerTabs.find((tab) => tab.hasAvailable)?.key ?? providerTabs[0]?.key ?? null; + }); + }, [activeProviderFamily, expandedModels, providerTabs, value]); + + const visibleModels = useMemo(() => { + if (providerTabs.length <= 1 || !activeProviderTabKey) return candidateModels; + return providerTabs.find((tab) => tab.key === activeProviderTabKey)?.models ?? candidateModels; + }, [activeProviderTabKey, candidateModels, providerTabs]); - const showSubHeaders = groupedRows.length > 1; + const groupedRows = useMemo(() => { + return [{ subProvider: "", models: visibleModels }]; + }, [visibleModels]); const [focusedIndex, setFocusedIndex] = useState(0); useEffect(() => { @@ -384,14 +448,6 @@ export const ModelPickerContent = memo(function ModelPickerContent({ const isEmpty = visibleModels.length === 0; - // Family of the active provider rail (null when on Favorites/Recents/search) - // — used to decide whether to render the inline "Set up {Provider}" banner. - const activeProviderFamily = useMemo(() => { - if (searchActive) return null; - if (selection === "favorites" || selection === "recents") return null; - return selection.slice("provider:".length) as ProviderFamily; - }, [searchActive, selection]); - // Show the setup banner only when the active provider rail is unauthed AND // the caller has wired a sign-in handler. Auth status of `undefined` (no // signal yet) is treated as "not unauthed" so the banner doesn't flash @@ -409,7 +465,8 @@ export const ModelPickerContent = memo(function ModelPickerContent({ activeProviderFamily != null && onOpenSignIn != null && effectiveAuth[activeProviderFamily] === "unauthed" - && !activeFamilyNeedsOpencode; + && !activeFamilyNeedsOpencode + && !activeProviderRefreshing; // Sticky "Currently using" detection — show when active row is not in the visible window. const activeRowVisibleRef = useRef(true); @@ -510,13 +567,36 @@ export const ModelPickerContent = memo(function ModelPickerContent({
-
- {activeOutOfView && activeModel ? ( +
+ {providerTabs.length > 1 ? ( +
+ {providerTabs.map((tab) => { + const active = tab.key === activeProviderTabKey; + return ( + + ); + })} +
+ ) : null} + + {activeOutOfView && activeModel ? (
) : (
- {groupedRows.map((group, gi) => ( -
- {showSubHeaders && group.subProvider ? ( -
- {group.subProvider} -
- ) : null} - {group.models.map((m) => { + {groupedRows.map((group, gi) => ( +
+ {group.models.map((m) => { const indexInFlat = flatVisibleIds.indexOf(m.id); const isFocused = indexInFlat === focusedIndex; const isActive = m.id === value; @@ -601,12 +672,14 @@ function EmptyState({ searchActive, opencodeBinaryInstalled, opencodeBinaryKnown, + refreshingProvider, onOpenSignIn, }: { selection: RailSelection; searchActive: boolean; opencodeBinaryInstalled: boolean; opencodeBinaryKnown: boolean; + refreshingProvider?: AgentChatModelCatalogRefreshProvider | null; onOpenSignIn?: () => void; }) { if (!searchActive && selection !== "favorites" && selection !== "recents") { @@ -624,6 +697,21 @@ function EmptyState({ /> ); } + if (refreshingProvider) { + const label = refreshProviderLabel(refreshingProvider); + return ( +
+ Checking {label} + + Loading the cached catalog and refreshing it in the background. + +
+ ); + } return ; } let body = "No models match this view."; diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.test.tsx index 80f339ee6..dd6fbb077 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.test.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.test.tsx @@ -53,12 +53,18 @@ vi.mock("./useReasoningByFamily", () => ({ })); import { ReasoningEffortPicker } from "./ReasoningEffortPicker"; +import { + descriptorsFromAgentChatModelCatalog, + resetRuntimeCatalogDescriptorCacheForTests, +} from "./modelCatalog"; +import type { AgentChatModelCatalog } from "../../../../shared/types"; const ANTHROPIC_MODEL_ID = "anthropic/claude-sonnet-4-6"; const OPENCODE_MODEL_ID = "opencode/some-model-without-reasoning"; beforeEach(() => { for (const key of Object.keys(reasoningByFamilyStore)) delete reasoningByFamilyStore[key]; + resetRuntimeCatalogDescriptorCacheForTests(); }); afterEach(() => { @@ -134,6 +140,58 @@ describe("ReasoningEffortPicker", () => { expect(radios.length).toBeGreaterThan(0); }); + it("uses cached runtime catalog reasoning tiers for dynamic runtime models", () => { + const catalog: AgentChatModelCatalog = { + fetchedAt: new Date().toISOString(), + groups: [ + { + key: "droid", + displayName: "Droid", + providers: [ + { + key: "factory", + displayName: "Factory", + badgeColor: "#60A5FA", + modelCount: 1, + subsections: [ + { + key: "factory", + label: "Factory", + models: [ + { + id: "droid/gpt-5.4", + runtimeModelId: "droid/gpt-5.4", + provider: "droid", + providerKey: "factory", + groupKey: "droid", + displayName: "GPT-5.4", + isDefault: true, + isAvailable: true, + reasoningEfforts: [{ effort: "max", description: "Max" }], + supportsReasoning: true, + supportsTools: true, + }, + ], + }, + ], + }, + ], + }, + ], + }; + descriptorsFromAgentChatModelCatalog(catalog); + + render( + , + ); + + expect(screen.getByRole("button", { name: /Reasoning effort/i }).textContent).toContain("MAX"); + }); + it("calls onChange and persists the tier when a tier is selected", async () => { const user = userEvent.setup(); const onChange = vi.fn(); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx index 79d986a0c..32f01b1ae 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx @@ -1,8 +1,9 @@ import { forwardRef, memo, useCallback, useMemo, useState } from "react"; import * as Popover from "@radix-ui/react-popover"; import { CaretDown } from "@phosphor-icons/react"; -import { resolveModelDescriptor, type ModelDescriptor } from "../../../../shared/modelRegistry"; +import type { ModelDescriptor } from "../../../../shared/modelRegistry"; import { cn } from "../../ui/cn"; +import { resolveModelDescriptorWithRuntimeCatalog } from "./modelCatalog"; import { useReasoningByFamily } from "./useReasoningByFamily"; export type ReasoningEffortPickerProps = { @@ -47,7 +48,7 @@ export const ReasoningEffortPicker = memo(function ReasoningEffortPicker({ const { rememberReasoning, getReasoningForFamily } = useReasoningByFamily(); const descriptor = useMemo( - () => (modelId ? resolveModelDescriptor(modelId) : undefined), + () => resolveModelDescriptorWithRuntimeCatalog(modelId), [modelId], ); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts index 3d74e9659..1ec146ce1 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts @@ -1,7 +1,17 @@ -import { describe, expect, it } from "vitest"; -import { mergeSelectorModels } from "./modelCatalog"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + descriptorsFromAgentChatModelCatalog, + mergeSelectorModels, + resetRuntimeCatalogDescriptorCacheForTests, + resolveModelDescriptorWithRuntimeCatalog, +} from "./modelCatalog"; +import type { AgentChatModelCatalog } from "../../../../shared/types"; describe("mergeSelectorModels", () => { + beforeEach(() => { + resetRuntimeCatalogDescriptorCacheForTests(); + }); + it("re-buckets OpenCode-routed models into the 'opencode' family so they appear under one rail", () => { const ids = [ "opencode/anthropic/claude-sonnet-4-6", @@ -55,4 +65,55 @@ describe("mergeSelectorModels", () => { expect(droidModels.length).toBe(1); expect(droidModels[0]!.id).toBe("droid/some-custom-model"); }); + + it("preserves runtime catalog reasoning and service tiers as a descriptor overlay", () => { + const catalog: AgentChatModelCatalog = { + fetchedAt: new Date().toISOString(), + groups: [ + { + key: "cursor", + displayName: "Cursor", + providers: [ + { + key: "cursor", + displayName: "Cursor", + badgeColor: "#60A5FA", + modelCount: 1, + subsections: [ + { + key: "cursor", + label: "Cursor", + models: [ + { + id: "cursor/composer-2", + runtimeModelId: "cursor/composer-2", + provider: "cursor", + providerKey: "cursor", + groupKey: "cursor", + displayName: "Composer 2", + isDefault: true, + isAvailable: true, + reasoningEfforts: [{ effort: "high", description: "High" }], + serviceTiers: ["fast"], + supportsReasoning: true, + supportsTools: true, + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const result = descriptorsFromAgentChatModelCatalog(catalog); + expect(result.models[0]).toMatchObject({ + id: "cursor/composer-2", + reasoningTiers: ["high"], + serviceTiers: ["fast"], + capabilities: expect.objectContaining({ reasoning: true, tools: true }), + }); + expect(resolveModelDescriptorWithRuntimeCatalog("cursor/composer-2")?.reasoningTiers).toEqual(["high"]); + }); }); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts index 3813b4840..ba894af8e 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts @@ -9,9 +9,29 @@ import { parseLocalProviderFromModelId, resolveModelDescriptor, type ModelDescriptor, + type ProviderFamily, } from "../../../../shared/modelRegistry"; +import type { AgentChatModelCatalog } from "../../../../shared/types"; import { PROVIDER_BADGE_COLORS } from "../providerModelSelectorGrouping"; +const runtimeCatalogDescriptorsById = new Map(); + +export function resetRuntimeCatalogDescriptorCacheForTests(): void { + runtimeCatalogDescriptorsById.clear(); +} + +export function getRuntimeCatalogModelDescriptor(modelId: string | null | undefined): ModelDescriptor | undefined { + const id = modelId?.trim(); + if (!id) return undefined; + return runtimeCatalogDescriptorsById.get(id); +} + +export function resolveModelDescriptorWithRuntimeCatalog(modelId: string | null | undefined): ModelDescriptor | undefined { + const id = modelId?.trim(); + if (!id) return undefined; + return getRuntimeCatalogModelDescriptor(id) ?? resolveModelDescriptor(id); +} + export function createUnknownModelPlaceholder(modelId: string): ModelDescriptor { const openCode = parseDynamicOpenCodeModelRef(modelId); if (openCode) { @@ -118,7 +138,7 @@ export function mergeSelectorModels( } for (const rawId of availableIdSet) { - const descriptor = resolveModelDescriptor(rawId); + const descriptor = resolveModelDescriptorWithRuntimeCatalog(rawId); if (descriptor) { if (descriptor.deprecated) continue; if (filter && !filter(descriptor)) continue; @@ -131,7 +151,7 @@ export function mergeSelectorModels( } if (selectedId && !merged.has(selectedId)) { - const selectedDescriptor = resolveModelDescriptor(selectedId); + const selectedDescriptor = resolveModelDescriptorWithRuntimeCatalog(selectedId); if (selectedDescriptor && !selectedDescriptor.deprecated && (!filter || filter(selectedDescriptor))) { merged.set(selectedDescriptor.id, rebucketOpenCodeFamily(selectedDescriptor)); } else if (!selectedDescriptor) { @@ -143,3 +163,83 @@ export function mergeSelectorModels( } return [...merged.values()]; } + +export type RuntimeCatalogModelDescriptor = ModelDescriptor & { + subProvider?: string; + subProviderKey?: string; + catalogGroupKey?: string; + catalogAvailable?: boolean; + catalogRequiresConfiguration?: boolean; +}; + +function pickerFamilyForCatalogGroup(groupKey: string, fallbackFamily?: string): ProviderFamily { + if (groupKey === "claude") return "anthropic"; + if (groupKey === "codex") return "openai"; + if (groupKey === "droid") return "factory"; + if (groupKey === "cursor") return "cursor"; + if (groupKey === "opencode") return "opencode"; + if (groupKey === "ollama") return "ollama"; + if (groupKey === "lmstudio") return "lmstudio"; + if (fallbackFamily === "ollama" || fallbackFamily === "lmstudio" || fallbackFamily === "cursor" || fallbackFamily === "factory") { + return fallbackFamily; + } + if (fallbackFamily === "anthropic" || fallbackFamily === "openai" || fallbackFamily === "opencode") { + return fallbackFamily; + } + return "opencode"; +} + +export function descriptorsFromAgentChatModelCatalog( + catalog: AgentChatModelCatalog | null | undefined, + filter?: (model: ModelDescriptor) => boolean, +): { models: RuntimeCatalogModelDescriptor[]; availableModelIds: string[] } { + if (!catalog) return { models: [], availableModelIds: [] }; + const merged = new Map(); + const available = new Set(); + for (const group of catalog.groups ?? []) { + for (const provider of group.providers ?? []) { + for (const subsection of provider.subsections ?? []) { + for (const model of subsection.models ?? []) { + const base = resolveModelDescriptor(model.id) ?? createUnknownModelPlaceholder(model.id); + const family = pickerFamilyForCatalogGroup(String(model.groupKey || group.key), model.family); + const runtimeReasoningTiers = model.reasoningEfforts + ?.map((entry) => entry.effort.trim().toLowerCase()) + .filter(Boolean); + const serviceTiers = model.serviceTiers + ?.map((entry) => entry.trim().toLowerCase()) + .filter(Boolean); + const capabilities = { + ...base.capabilities, + ...(typeof model.supportsReasoning === "boolean" ? { reasoning: model.supportsReasoning } : {}), + ...(typeof model.supportsTools === "boolean" ? { tools: model.supportsTools } : {}), + }; + const descriptor: RuntimeCatalogModelDescriptor = { + ...rebucketOpenCodeFamily(base), + id: model.id, + displayName: model.displayName || base.displayName, + shortId: base.shortId, + family, + color: model.color ?? base.color, + capabilities, + ...(runtimeReasoningTiers?.length ? { reasoningTiers: runtimeReasoningTiers } : {}), + ...(model.serviceTiers !== undefined + ? { serviceTiers } + : base.serviceTiers?.length + ? { serviceTiers: base.serviceTiers } + : {}), + subProvider: model.providerName || provider.displayName || subsection.label || undefined, + subProviderKey: model.providerId || provider.key || subsection.key || undefined, + catalogGroupKey: String(model.groupKey || group.key), + catalogAvailable: model.isAvailable, + catalogRequiresConfiguration: model.requiresConfiguration, + }; + if (filter && !filter(descriptor)) continue; + merged.set(descriptor.id, descriptor); + runtimeCatalogDescriptorsById.set(descriptor.id, descriptor); + if (model.isAvailable) available.add(descriptor.id); + } + } + } + } + return { models: [...merged.values()], availableModelIds: [...available] }; +} diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts new file mode 100644 index 000000000..db1ee412a --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts @@ -0,0 +1,107 @@ +import type { AgentChatModelCatalog, AgentChatModelCatalogRefreshProvider } from "../../../../shared/types"; + +const RUNTIME_CATALOG_REFRESH_TTL_MS = 30 * 60_000; +const RUNTIME_CATALOG_LOCAL_REFRESH_TTL_MS = 30_000; +const REFRESH_PROVIDERS: AgentChatModelCatalogRefreshProvider[] = [ + "opencode", + "cursor", + "droid", + "lmstudio", + "ollama", +]; + +let sharedRuntimeCatalog: AgentChatModelCatalog | null = null; +const sharedRuntimeCatalogProviderRefreshedAt = new Map(); +const sharedRuntimeCatalogRequests = new Map>(); + +export function resetModelPickerRuntimeCatalogForTests(): void { + sharedRuntimeCatalog = null; + sharedRuntimeCatalogProviderRefreshedAt.clear(); + sharedRuntimeCatalogRequests.clear(); +} + +export function getSharedRuntimeCatalog(): AgentChatModelCatalog | null { + return sharedRuntimeCatalog; +} + +function runtimeCatalogRefreshTtlMs(provider?: AgentChatModelCatalogRefreshProvider): number { + return provider === "lmstudio" || provider === "ollama" + ? RUNTIME_CATALOG_LOCAL_REFRESH_TTL_MS + : RUNTIME_CATALOG_REFRESH_TTL_MS; +} + +function catalogContainsRefreshProvider( + catalog: AgentChatModelCatalog, + provider: AgentChatModelCatalogRefreshProvider, +): boolean { + return (catalog.groups ?? []).some((group) => { + const groupMatches = provider === "droid" + ? group.key === "droid" + : group.key === provider; + if (!groupMatches) return false; + return (group.providers ?? []).some((entry) => entry.modelCount > 0); + }); +} + +function markRuntimeCatalogProviderFresh( + provider: AgentChatModelCatalogRefreshProvider, + refreshedAt = Date.now(), +): void { + sharedRuntimeCatalogProviderRefreshedAt.set(provider, refreshedAt); +} + +export function runtimeCatalogProviderIsFresh(provider: AgentChatModelCatalogRefreshProvider): boolean { + const refreshedAt = sharedRuntimeCatalogProviderRefreshedAt.get(provider); + return Boolean(refreshedAt && Date.now() - refreshedAt <= runtimeCatalogRefreshTtlMs(provider)); +} + +export function rememberRuntimeCatalog( + catalog: AgentChatModelCatalog, + args: { mode: "cached" | "refresh-stale" | "force"; refreshProvider?: AgentChatModelCatalogRefreshProvider }, +): AgentChatModelCatalog { + if (args.mode === "cached" && sharedRuntimeCatalog) { + for (const provider of REFRESH_PROVIDERS) { + if ( + runtimeCatalogProviderIsFresh(provider) + && catalogContainsRefreshProvider(sharedRuntimeCatalog, provider) + && !catalogContainsRefreshProvider(catalog, provider) + ) { + return sharedRuntimeCatalog; + } + } + } + + sharedRuntimeCatalog = catalog; + if (args.refreshProvider && (args.mode === "force" || catalog.stale !== true)) { + markRuntimeCatalogProviderFresh(args.refreshProvider); + return catalog; + } + if (args.mode === "cached" && catalog.stale !== true) { + for (const provider of REFRESH_PROVIDERS) { + if (catalogContainsRefreshProvider(catalog, provider)) { + markRuntimeCatalogProviderFresh(provider); + } + } + } + return catalog; +} + +export function getRuntimeCatalogRequest(key: string): Promise | undefined { + return sharedRuntimeCatalogRequests.get(key); +} + +export function setRuntimeCatalogRequest( + key: string, + request: Promise, +): void { + sharedRuntimeCatalogRequests.set(key, request); +} + +export function clearRuntimeCatalogRequest( + key: string, + request: Promise, +): void { + if (sharedRuntimeCatalogRequests.get(key) === request) { + sharedRuntimeCatalogRequests.delete(key); + } +} diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts index a3f340184..4d34c8633 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts @@ -87,32 +87,6 @@ async function probeBinary(): Promise { } } -async function fetchStatus(): Promise { - const store = useProviderAuthStore.getState(); - if (store.inFlight) return store.inFlight; - const ade = (window as unknown as { ade?: { ai?: { getStatus?: (args?: unknown) => Promise } } }).ade; - const getStatus = ade?.ai?.getStatus; - if (typeof getStatus !== "function") { - store.setStatus({}, false); - return; - } - const promise = (async () => { - try { - const raw = (await getStatus()) as Parameters[0] & { - opencodeBinaryInstalled?: unknown; - }; - const safe = raw ?? {}; - useProviderAuthStore - .getState() - .setStatus(familiesFromStatus(safe), opencodeBinaryInstalledFromStatus(safe)); - } catch { - useProviderAuthStore.getState().setStatus({}, false); - } - })(); - store.setInFlight(promise); - return promise; -} - export function useProviderAuthStatus(): { status: AuthStatusMap; opencodeBinaryInstalled: boolean; @@ -128,13 +102,10 @@ export function useProviderAuthStatus(): { loaded: state.loaded, })), ); - // Run BOTH on mount in parallel: cheap binary probe (ms) gives us the - // opencode-installed signal immediately so the picker doesn't flash the - // wrong empty state; the full fetchStatus continues in the background to - // populate connected providers + their model lists. + // Keep picker open cheap: only resolve the OpenCode binary here. Runtime + // catalog refreshes are triggered by selecting the relevant rail. useEffect(() => { void probeBinary(); - void fetchStatus(); }, []); return slice; } diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 39ccb7758..a986aed12 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -192,6 +192,7 @@ export const IPC = { agentChatApprove: "ade.agentChat.approve", agentChatRespondToInput: "ade.agentChat.respondToInput", agentChatModels: "ade.agentChat.models", + agentChatModelCatalog: "ade.agentChat.modelCatalog", agentChatDelete: "ade.agentChat.delete", agentChatArchive: "ade.agentChat.archive", agentChatUnarchive: "ade.agentChat.unarchive", diff --git a/apps/desktop/src/shared/modelCatalog.ts b/apps/desktop/src/shared/modelCatalog.ts index e44ea4b59..7035c5504 100644 --- a/apps/desktop/src/shared/modelCatalog.ts +++ b/apps/desktop/src/shared/modelCatalog.ts @@ -12,7 +12,7 @@ import { type ModelProviderGroup, } from "./modelRegistry"; -export type ProviderGroupKey = ModelProviderGroup; +export type ProviderGroupKey = ModelProviderGroup | "ollama" | "lmstudio"; export type ProviderCategory = "cloud-api" | "local" | "router"; @@ -120,6 +120,8 @@ const PROVIDER_GROUP_ORDER: Record = { cursor: 30, droid: 35, opencode: 40, + ollama: 50, + lmstudio: 60, }; export const PROVIDER_GROUP_COLORS: Record = { @@ -128,6 +130,8 @@ export const PROVIDER_GROUP_COLORS: Record = { cursor: "#A78BFA", droid: "#6B7280", opencode: "#2563EB", + ollama: "#71717A", + lmstudio: "#64748B", }; const CURSOR_SECTION_PREFIX = "__cursor_line__:"; @@ -144,6 +148,9 @@ export function providerBadgeColor(provider: string, models: ModelDescriptor[]): export function classifyProviderGroup(model: ModelDescriptor): ProviderGroupKey { if (model.family === "cursor") return "cursor"; + if (model.providerRoute === "opencode" && (model.family === "ollama" || model.family === "lmstudio")) { + return model.family; + } if (model.isCliWrapped) { if (model.family === "anthropic" || model.cliCommand === "claude") return "claude"; if (model.family === "openai" || model.cliCommand === "codex") return "codex"; @@ -164,6 +171,10 @@ export function providerGroupLabel(group: ProviderGroupKey): string { return "Droid"; case "opencode": return "OpenCode"; + case "ollama": + return "Ollama"; + case "lmstudio": + return "LM Studio"; } } @@ -247,21 +258,6 @@ function compareProviderKeys(a: string, b: string): number { return (ia === -1 ? Number.MAX_SAFE_INTEGER : ia) - (ib === -1 ? Number.MAX_SAFE_INTEGER : ib); } -export const OPENCODE_FALLBACK_PROVIDERS: string[] = [ - "opencode", - "anthropic", - "openai", - "google", - "deepseek", - "mistral", - "xai", - "groq", - "together", - "openrouter", - "ollama", - "lmstudio", -]; - export function sortOpenCodeProvidersByCategory(providers: ModelProviderBlock[]): { cloud: ModelProviderBlock[]; local: ModelProviderBlock[]; @@ -286,11 +282,16 @@ export function buildProviderGroupBlocks( includeEmptyOpenCodeProviders = true, ): ModelProviderGroupBlock[] { const byGroup = new Map>>(); + const opencodeProviderNameById = new Map((opencodeProviders ?? []).map((provider) => [provider.id, provider.name] as const)); for (const model of models) { const group = classifyProviderGroup(model); - const family = model.family; - const subKey = subsectionKeyForModel(model, group); + const family = group === "opencode" && model.openCodeProviderId + ? model.openCodeProviderId + : model.family; + const subKey = group === "opencode" && model.openCodeProviderId + ? "__default__" + : subsectionKeyForModel(model, group); let famMap = byGroup.get(group); if (!famMap) { famMap = new Map(); @@ -338,7 +339,9 @@ export function buildProviderGroupBlocks( const modelCount = subsections.reduce((acc, sub) => acc + sub.models.length, 0); providers.push({ key: family, - label: providerLabel(family), + label: groupKey === "opencode" + ? opencodeProviderNameById.get(family) ?? providerLabel(family) + : providerLabel(family), badgeColor: providerBadgeColor(family, subsections.flatMap((s) => s.models)), subsections, modelCount, @@ -347,10 +350,9 @@ export function buildProviderGroupBlocks( if (groupKey === "opencode" && includeEmptyOpenCodeProviders) { const existingFamilies = new Set(providers.map((p) => p.key)); - const potentialProviders = opencodeProviders?.length - ? opencodeProviders.map((p) => ({ id: p.id, name: p.name })) - : OPENCODE_FALLBACK_PROVIDERS.map((id) => ({ id, name: providerLabel(id) })); + const potentialProviders = opencodeProviders?.map((p) => ({ id: p.id, name: p.name })) ?? []; for (const { id, name } of potentialProviders) { + if (id === "ollama" || id === "lmstudio") continue; if (!existingFamilies.has(id)) { providers.push({ key: id, diff --git a/apps/desktop/src/shared/modelRegistry.ts b/apps/desktop/src/shared/modelRegistry.ts index 15018c0f0..5e7390342 100644 --- a/apps/desktop/src/shared/modelRegistry.ts +++ b/apps/desktop/src/shared/modelRegistry.ts @@ -501,8 +501,10 @@ export type DynamicOpenCodeModelDescriptorOptions = { maxOutputTokens?: number; capabilities?: Partial; reasoningTiers?: string[]; + serviceTiers?: string[]; aliases?: string[]; color?: string; + harnessProfile?: LocalModelHarnessProfile; /** When set with openCodeModelId, registry id is derived so model ids may contain `/`. */ openCodeProviderId?: string; openCodeModelId?: string; @@ -621,7 +623,9 @@ export function createDynamicOpenCodeModelDescriptor( providerModelId, ...(usesPairedIds ? { openCodeProviderId: opPid, openCodeModelId: opMid } : {}), ...(options?.reasoningTiers?.length ? { reasoningTiers: [...options.reasoningTiers] } : {}), + ...(options?.serviceTiers?.length ? { serviceTiers: [...options.serviceTiers] } : {}), ...(aliases.length ? { aliases } : {}), + ...(isLocal || options?.harnessProfile ? { harnessProfile: options?.harnessProfile ?? "guarded" } : {}), isCliWrapped: false, }; } @@ -736,6 +740,11 @@ export function parseDynamicCursorModelRef(modelId: string): { providerModelId: export function createDynamicCursorCliModelDescriptor( providerModelId: string, cliDisplayName?: string | null, + options?: { + reasoningTiers?: string[]; + serviceTiers?: string[]; + capabilities?: Partial; + }, ): ModelDescriptor { const id = `cursor/${providerModelId}`; const display = @@ -750,11 +759,16 @@ export function createDynamicCursorCliModelDescriptor( authTypes: ["api-key"], contextWindow: 200_000, maxOutputTokens: 32_000, - capabilities: ALL_CAPS, + capabilities: { + ...ALL_CAPS, + ...(options?.capabilities ?? {}), + }, color: colorForCursorSdkId(providerModelId), providerRoute: "cursor-sdk", providerModelId, cliCommand: "cursor", + ...(options?.reasoningTiers?.length ? { reasoningTiers: [...options.reasoningTiers] } : {}), + ...(options?.serviceTiers?.length ? { serviceTiers: [...options.serviceTiers] } : {}), isCliWrapped: false, }; } @@ -942,7 +956,12 @@ export function parseDynamicDroidModelRef(modelId: string): { providerModelId: s export function createDynamicDroidCliModelDescriptor( providerModelId: string, cliDisplayName?: string | null, - options?: { customProxy?: boolean }, + options?: { + customProxy?: boolean; + reasoningTiers?: string[]; + serviceTiers?: string[]; + capabilities?: Partial; + }, ): ModelDescriptor { const trimmedProviderModelId = providerModelId.trim(); const id = `droid/${trimmedProviderModelId}`; @@ -961,11 +980,16 @@ export function createDynamicDroidCliModelDescriptor( authTypes: ["cli-subscription"], contextWindow: 200_000, maxOutputTokens: 32_000, - capabilities: ALL_CAPS, + capabilities: { + ...ALL_CAPS, + ...(options?.capabilities ?? {}), + }, color: colorForDroidModelId(trimmedProviderModelId), providerRoute: "droid-cli", providerModelId: trimmedProviderModelId, cliCommand: "droid", + ...(options?.reasoningTiers?.length ? { reasoningTiers: [...options.reasoningTiers] } : {}), + ...(options?.serviceTiers?.length ? { serviceTiers: [...options.serviceTiers] } : {}), isCliWrapped: true, ...(options?.customProxy ? { customProxy: true } : {}), }; diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 6d2601d55..7c3962669 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -975,6 +975,12 @@ export type AgentChatModelCatalogModel = AgentChatModelInfo & { providerKey: string; groupKey: AgentChatProvider; isAvailable: boolean; + connected?: boolean; + requiresConfiguration?: boolean; + sourceRuntime?: AgentChatProvider; + providerId?: string; + providerName?: string; + stale?: boolean; }; export type AgentChatModelCatalogSubsection = { @@ -1000,6 +1006,21 @@ export type AgentChatModelCatalogGroup = { export type AgentChatModelCatalog = { groups: AgentChatModelCatalogGroup[]; fetchedAt: string; + stale?: boolean; +}; + +export type AgentChatModelCatalogRefreshProvider = + | "opencode" + | "cursor" + | "droid" + | "lmstudio" + | "ollama"; + +export type AgentChatModelCatalogMode = "cached" | "refresh-stale" | "force"; + +export type AgentChatModelCatalogArgs = { + mode?: AgentChatModelCatalogMode; + refreshProvider?: AgentChatModelCatalogRefreshProvider; }; export type AgentChatCreateArgs = { diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index f7edfb393..a5af6f057 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -703,7 +703,12 @@ export type SyncRemoteCommandAction = | "prs.pipelineSettings.delete" | "prs.pathToMerge.start" | "prs.pathToMerge.stop" - | "prs.getMobileSnapshot"; + | "prs.getMobileSnapshot" + | "modelPicker.getFavorites" + | "modelPicker.setFavorites" + | "modelPicker.toggleFavorite" + | "modelPicker.getRecents" + | "modelPicker.pushRecent"; export type SyncRemoteCommandPolicy = { viewerAllowed: boolean; diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 5fd21c57a..28efd5377 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -1913,8 +1913,14 @@ struct AgentChatModelCatalogModel: Codable, Equatable, Identifiable { var family: String? var supportsReasoning: Bool? var supportsTools: Bool? - var color: String? - var isAvailable: Bool + var color: String? + var isAvailable: Bool + var connected: Bool? + var requiresConfiguration: Bool? + var sourceRuntime: String? + var providerId: String? + var providerName: String? + var stale: Bool? } struct AgentChatModelCatalogSubsection: Codable, Equatable, Identifiable { @@ -1943,6 +1949,7 @@ struct AgentChatModelCatalogGroup: Codable, Equatable, Identifiable { struct AgentChatModelCatalog: Codable, Equatable { var groups: [AgentChatModelCatalogGroup] var fetchedAt: String + var stale: Bool? } /// Response envelopes for the cross-surface ModelPicker favorites/recents diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index bf95bd6ed..8f03ad235 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -3637,41 +3637,62 @@ final class SyncService: ObservableObject { } } - @MainActor - func cachedChatModelCatalog() -> AgentChatModelCatalog? { - let cacheKey = chatModelsCacheKey(provider: "catalog") - guard let cached = chatModelCatalogCache[cacheKey] else { return nil } - guard Date().timeIntervalSince(cached.fetchedAt) < chatModelsCacheTTL else { return nil } - return cached.catalog - } + @MainActor + func cachedChatModelCatalog() -> AgentChatModelCatalog? { + guard let cached = chatModelCatalogCache.values.sorted(by: { $0.fetchedAt > $1.fetchedAt }).first else { return nil } + guard Date().timeIntervalSince(cached.fetchedAt) < chatModelsCacheTTL else { return nil } + return cached.catalog + } @MainActor - func getChatModelCatalog() async throws -> AgentChatModelCatalog { - let cacheKey = chatModelsCacheKey(provider: "catalog") + func getChatModelCatalog( + mode: String = "refresh-stale", + refreshProvider: String? = nil + ) async throws -> AgentChatModelCatalog { + let cacheKey = chatModelsCacheKey(provider: "catalog:\(mode):\(refreshProvider ?? "")") let now = Date() - if let cached = chatModelCatalogCache[cacheKey], + if mode != "force", + let cached = chatModelCatalogCache[cacheKey], now.timeIntervalSince(cached.fetchedAt) < chatModelsCacheTTL { return cached.catalog } + if mode == "cached" { + if let cached = chatModelCatalogCache[cacheKey], + now.timeIntervalSince(cached.fetchedAt) < chatModelsCacheTTL { + return cached.catalog + } + if let cached = cachedChatModelCatalog() { + return cached + } + } + if let task = chatModelCatalogInFlight[cacheKey] { return try await task.value } let task = Task { @MainActor [weak self] in - guard let self else { throw CancellationError() } - return try await self.sendDecodableCommand( - action: "chat.modelCatalog", - args: [:], - as: AgentChatModelCatalog.self - ) + guard let self else { throw CancellationError() } + var args: [String: Any] = ["mode": mode] + if let refreshProvider { + args["refreshProvider"] = refreshProvider + } + return try await self.sendDecodableCommand( + action: "chat.modelCatalog", + args: args, + as: AgentChatModelCatalog.self + ) } chatModelCatalogInFlight[cacheKey] = task do { let catalog = try await task.value chatModelCatalogCache[cacheKey] = ChatModelCatalogCacheEntry(catalog: catalog, fetchedAt: now) + if mode == "force", let refreshProvider { + let refreshStaleKey = chatModelsCacheKey(provider: "catalog:refresh-stale:\(refreshProvider)") + chatModelCatalogCache[refreshStaleKey] = ChatModelCatalogCacheEntry(catalog: catalog, fetchedAt: now) + } chatModelCatalogInFlight[cacheKey] = nil return catalog } catch { @@ -3679,6 +3700,9 @@ final class SyncService: ObservableObject { if let cached = chatModelCatalogCache[cacheKey] { return cached.catalog } + if let cached = cachedChatModelCatalog() { + return cached + } throw error } } diff --git a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift index 36cfa8978..4dbe662b2 100644 --- a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift +++ b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift @@ -17,9 +17,11 @@ struct WorkModelOption: Identifiable, Hashable { /// (e.g. "claude" for the CLAUDE brand avatar). For OpenCode-routed /// models this is still the upstream family so the logo stays brand-true. let provider: String - /// Reasoning efforts supplied by the paired desktop host. Empty means the - /// host did not advertise a selectable reasoning control for this model. - let reasoningEfforts: [AgentChatModelReasoningEffort] + /// Reasoning efforts supplied by the paired desktop host. Empty means the + /// host did not advertise a selectable reasoning control for this model. + let reasoningEfforts: [AgentChatModelReasoningEffort] + let serviceTiers: [String] + let isAvailable: Bool init( id: String, @@ -27,19 +29,31 @@ struct WorkModelOption: Identifiable, Hashable { tier: Tier, tagline: String, provider: String, - reasoningEfforts: [AgentChatModelReasoningEffort] = [] - ) { + reasoningEfforts: [AgentChatModelReasoningEffort] = [], + serviceTiers: [String] = [], + isAvailable: Bool = true + ) { self.id = id self.displayName = displayName self.tier = tier self.tagline = tagline - self.provider = provider - self.reasoningEfforts = reasoningEfforts - } + self.provider = provider + self.reasoningEfforts = reasoningEfforts + self.serviceTiers = serviceTiers + self.isAvailable = isAvailable + } } extension WorkModelOption { enum Tier: String { case fast, balanced, flagship, reasoning } + + func supportsServiceTier(_ tier: String) -> Bool { + let needle = tier.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !needle.isEmpty else { return false } + return serviceTiers.contains { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == needle } + } + + var supportsCodexFastMode: Bool { supportsServiceTier("fast") } } /// One provider inside a group. Claude/Codex/Cursor groups almost always @@ -80,7 +94,7 @@ struct WorkModelCatalogGroupLegacyView: Identifiable, Hashable { let models: [WorkModelOption] } -private let workModelGroupOrder = ["claude", "codex", "cursor", "droid", "opencode"] +private let workModelGroupOrder = ["claude", "codex", "cursor", "droid", "opencode", "ollama", "lmstudio"] /// Flat view of the curated catalog: every model in a single provider tab so /// legacy call sites keep functioning. Prefer `workModelCatalogGroups` for @@ -275,20 +289,6 @@ private func workCuratedModelCatalogGroups() -> [WorkModelCatalogGroup] { WorkModelOption(id: "opencode/deepseek/deepseek-chat", displayName: "DeepSeek Chat", tier: .balanced, tagline: "DeepSeek chat", provider: "deepseek"), WorkModelOption(id: "opencode/deepseek/deepseek-coder", displayName: "DeepSeek Coder", tier: .balanced, tagline: "DeepSeek coder", provider: "deepseek"), ] - ), - WorkModelProvider( - key: "lmstudio", - displayName: "LM Studio", - models: [ - WorkModelOption(id: "opencode/lmstudio/auto", displayName: "LM Studio · Auto", tier: .fast, tagline: "Local LM Studio provider", provider: "lmstudio"), - ] - ), - WorkModelProvider( - key: "ollama", - displayName: "Ollama", - models: [ - WorkModelOption(id: "opencode/ollama/auto", displayName: "Ollama · Auto", tier: .fast, tagline: "Local Ollama provider", provider: "ollama"), - ] ) ] )) @@ -305,9 +305,8 @@ func workModelCatalogGroups(currentModelId: String, currentProvider: String) -> ) } -/// Live host-driven catalog used by the mobile Work picker. This mirrors the -/// desktop wiring more closely: the host decides which models are currently -/// available per runtime, while the curated catalog only fills in friendly +/// Live host-driven catalog used by the mobile Work picker. The host decides +/// which models exist per runtime; the local metadata only fills in friendly /// tiers/taglines and ordering. func workModelCatalogGroups( availableModelsByProvider: [String: [AgentChatModelInfo]], @@ -379,8 +378,7 @@ func workModelCatalogGroups( providers: group.providers.map { provider in let models = provider.subsections .flatMap(\.models) - .filter(\.isAvailable) - .map { model in + .map { model in workCatalogModelOption( from: model, topLevelProvider: group.key, @@ -393,7 +391,7 @@ func workModelCatalogGroups( models: workDeduplicatedModelOptions(models) ) } - .filter { !$0.models.isEmpty || group.key == "opencode" } + .filter { !$0.models.isEmpty } ) } .filter { !$0.providers.isEmpty } @@ -428,17 +426,21 @@ private func workCatalogModelOption( if model.supportsTools == true { parts.append("Tools") } - tagline = parts.isEmpty ? "Available on the paired machine" : parts.joined(separator: " · ") + tagline = model.isAvailable + ? (parts.isEmpty ? "Available on the paired machine" : parts.joined(separator: " · ")) + : "Configure this provider on the paired machine" } return WorkModelOption( id: model.id, displayName: displayName, tier: workDynamicModelTier(for: model.id), - tagline: tagline, + tagline: tagline, provider: workModelBrandKey(topLevelProvider: topLevelProvider, providerKey: providerKey), - reasoningEfforts: model.reasoningEfforts ?? [] - ) + reasoningEfforts: model.reasoningEfforts ?? [], + serviceTiers: model.serviceTiers ?? [], + isAvailable: model.isAvailable + ) } private func workCuratedModelLookup(from groups: [WorkModelCatalogGroup]) -> [String: WorkModelOption] { @@ -771,7 +773,8 @@ private func workDynamicModelOption( tier: workDynamicModelTier(for: model.id, curated: curated), tagline: tagline, provider: curated?.provider ?? workModelBrandKey(topLevelProvider: topLevelProvider, providerKey: providerKey), - reasoningEfforts: model.reasoningEfforts ?? [] + reasoningEfforts: model.reasoningEfforts ?? [], + serviceTiers: model.serviceTiers ?? [] ) } @@ -874,8 +877,11 @@ func workModelCatalogGroupKey(for currentModelId: String, currentProvider: Strin let provider = currentProvider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let modelId = currentModelId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if modelId.hasPrefix("opencode/") || provider == "opencode" { - return "opencode" + if provider == "lmstudio" || modelId.hasPrefix("opencode/lmstudio/") { + return "lmstudio" + } + if provider == "ollama" || modelId.hasPrefix("opencode/ollama/") { + return "ollama" } if provider == "droid" || provider == "factory" || modelId.hasPrefix("droid/") { return "droid" @@ -889,7 +895,10 @@ func workModelCatalogGroupKey(for currentModelId: String, currentProvider: Strin if provider == "openai" || provider == "codex" || modelId.hasPrefix("openai/") || modelId.contains("gpt") || modelId.contains("codex") { return "codex" } - if ["google", "xai", "deepseek", "lmstudio", "ollama"].contains(provider) { + if modelId.hasPrefix("opencode/") || provider == "opencode" { + return "opencode" + } + if ["google", "xai", "deepseek"].contains(provider) { return "opencode" } return provider.isEmpty ? "claude" : provider diff --git a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift index 0f38a0207..e055e8aa8 100644 --- a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift @@ -36,22 +36,18 @@ struct WorkModelPickerSheet: View { @StateObject private var picker = ModelPickerStore() @State private var selection: ModelPickerRailSelection = .favorites - @State private var searchText: String = "" - @State private var liveCatalog: [WorkModelCatalogGroup]? - @State private var isLoadingCatalog = false - @State private var usingCuratedFallback = false - @State private var didPickInitialSelection = false - - private var curatedCatalog: [WorkModelCatalogGroup] { - workModelCatalogGroups(currentModelId: currentModelId, currentProvider: currentProvider) - } - - private var catalog: [WorkModelCatalogGroup] { - if let liveCatalog { - return liveCatalog - } - return usingCuratedFallback ? curatedCatalog : [] - } + @State private var searchText: String = "" + @State private var liveCatalog: [WorkModelCatalogGroup]? + @State private var isLoadingCatalog = false + @State private var didPickInitialSelection = false + @State private var selectedProviderTabKey: String? + + private var catalog: [WorkModelCatalogGroup] { + if let liveCatalog { + return liveCatalog + } + return [] + } private var flattenedModels: [WorkModelOption] { var seen = Set() @@ -99,25 +95,34 @@ struct WorkModelPickerSheet: View { } else { Divider().overlay(ADEColor.glassBorder) HStack(spacing: 0) { - ModelPickerRail( + ModelPickerRail( entries: railEntries, selected: effectiveSelection, favoritesCount: picker.favorites.count, recentsCount: picker.recents.count, - onSelect: { selection = $0 } - ) + onSelect: { next in + selection = next + selectedProviderTabKey = nil + if case .providerGroup(let key, _) = next { + Task { await refreshCatalog(for: key) } + } + } + ) Divider().overlay(ADEColor.glassBorder) ModelPickerContentPane( selection: effectiveSelection, isSearching: isSearching, searchText: searchText, models: visibleModels, - groupedRows: groupedRows, - currentModelId: currentModelId, + groupedRows: groupedRows, + providerTabs: providerTabs, + selectedProviderTabKey: selectedProviderTabKey, + currentModelId: currentModelId, currentReasoningEffort: currentReasoningEffort, favorites: picker.favorites, isBusy: isBusy, - onSelect: { model, effort in commit(model: model, effort: effort) }, + onSelect: { model, effort in commit(model: model, effort: effort) }, + onSelectProviderTab: { selectedProviderTabKey = $0 }, onToggleFavorite: { picker.toggleFavorite($0, syncService: syncService) } ) } @@ -143,10 +148,13 @@ struct WorkModelPickerSheet: View { .onAppear { picker.load(syncService: syncService) } - .task(id: "\(currentModelId)\u{0}\(currentProvider)") { - await loadLiveCatalog() - pickInitialSelectionIfNeeded() - } + .task(id: "\(currentModelId)\u{0}\(currentProvider)") { + await loadLiveCatalog() + pickInitialSelectionIfNeeded() + if case .providerGroup(let key, _) = selection { + await refreshCatalog(for: key) + } + } } /// Falls back to the first available provider entry only when the user's @@ -202,8 +210,10 @@ struct WorkModelPickerSheet: View { case .recents: let lookup = modelById pool = picker.recents.compactMap { lookup[$0] } - case .providerGroup(let key, _): - pool = catalog.first(where: { $0.key == key })?.providers.flatMap { $0.models } ?? [] + case .providerGroup(let key, _): + let group = catalog.first(where: { $0.key == key }) + let providers = filteredProviders(for: group) + pool = providers.flatMap { $0.models } } } @@ -226,26 +236,29 @@ struct WorkModelPickerSheet: View { switch effectiveSelection { case .favorites, .recents: return [ModelPickerRowGroup(id: "_root", title: nil, models: visibleModels)] - case .providerGroup(let key, _): - guard let group = catalog.first(where: { $0.key == key }) else { - return [ModelPickerRowGroup(id: "_root", title: nil, models: visibleModels)] - } - let providers = group.providers - // Single-provider groups (Claude, Codex) skip sub-headers entirely. - if providers.count <= 1 { - return [ModelPickerRowGroup(id: "_only", title: nil, models: visibleModels)] - } - return providers.compactMap { provider -> ModelPickerRowGroup? in - guard !provider.models.isEmpty else { return nil } - return ModelPickerRowGroup( - id: provider.key, - title: provider.displayName, - models: provider.models - ) - } + case .providerGroup: + return [ModelPickerRowGroup(id: "_root", title: nil, models: visibleModels)] } } + private var providerTabs: [WorkModelProvider] { + guard !isSearching else { return [] } + guard case .providerGroup(let key, _) = effectiveSelection else { return [] } + return catalog.first(where: { $0.key == key })?.providers.filter { !$0.models.isEmpty } ?? [] + } + + private func filteredProviders(for group: WorkModelCatalogGroup?) -> [WorkModelProvider] { + guard let group else { return [] } + let providers = group.providers.filter { !$0.models.isEmpty } + guard providers.count > 1 else { return providers } + let activeKey = selectedProviderTabKey + ?? providers.first(where: { provider in provider.models.contains(where: { workModelIdsEquivalent($0.id, currentModelId) }) })?.key + ?? providers.first(where: { provider in provider.models.contains(where: { $0.isAvailable }) })?.key + ?? providers.first?.key + guard let activeKey else { return providers } + return providers.filter { $0.key == activeKey } + } + private func catalogGroupContaining(modelId: String) -> String? { for group in catalog { for provider in group.providers { @@ -263,9 +276,13 @@ struct WorkModelPickerSheet: View { provider.models.contains { $0.id == model.id } } }) { + if group.key == "lmstudio" || group.key == "ollama" { + return "opencode" + } return group.key } - return workModelCatalogGroupKey(for: model.id, currentProvider: currentProvider) + let fallback = workModelCatalogGroupKey(for: model.id, currentProvider: currentProvider) + return fallback == "lmstudio" || fallback == "ollama" ? "opencode" : fallback } private func groupLabel(_ group: WorkModelCatalogGroup) -> String { @@ -346,38 +363,63 @@ struct WorkModelPickerSheet: View { // MARK: Behavior - @MainActor - private func loadLiveCatalog() async { - isLoadingCatalog = true - defer { isLoadingCatalog = false } - usingCuratedFallback = false + @MainActor + private func loadLiveCatalog() async { + isLoadingCatalog = true + defer { isLoadingCatalog = false } - if liveCatalog == nil, let cached = syncService.cachedChatModelCatalog() { + if liveCatalog == nil, let cached = syncService.cachedChatModelCatalog() { liveCatalog = workModelCatalogGroups( hostCatalog: cached, currentModelId: currentModelId, currentProvider: currentProvider ) - } + } - do { - let hostCatalog = try await syncService.getChatModelCatalog() + do { + let hostCatalog = try await syncService.getChatModelCatalog(mode: "cached") guard !Task.isCancelled else { return } - liveCatalog = workModelCatalogGroups( - hostCatalog: hostCatalog, - currentModelId: currentModelId, - currentProvider: currentProvider - ) - usingCuratedFallback = false - } catch { - guard !Task.isCancelled else { return } - if liveCatalog == nil { - usingCuratedFallback = true - } - } - } - - private func commit(model: WorkModelOption, effort: String?) { + apply(hostCatalog: hostCatalog) + } catch { + guard !Task.isCancelled else { return } + } + } + + @MainActor + private func refreshCatalog(for groupKey: String) async { + let refreshProvider: String? + switch groupKey { + case "opencode", "cursor", "droid", "lmstudio", "ollama": + refreshProvider = groupKey + default: + refreshProvider = nil + } + guard let refreshProvider else { return } + do { + let hostCatalog = try await syncService.getChatModelCatalog(mode: "refresh-stale", refreshProvider: refreshProvider) + guard !Task.isCancelled else { return } + apply(hostCatalog: hostCatalog) + if hostCatalog.stale == true { + let freshCatalog = try await syncService.getChatModelCatalog(mode: "force", refreshProvider: refreshProvider) + guard !Task.isCancelled else { return } + apply(hostCatalog: freshCatalog) + } + } catch { + // Keep stale catalog visible. + } + } + + @MainActor + private func apply(hostCatalog: AgentChatModelCatalog) { + liveCatalog = workModelCatalogGroups( + hostCatalog: hostCatalog, + currentModelId: currentModelId, + currentProvider: currentProvider + ) + } + + private func commit(model: WorkModelOption, effort: String?) { + guard model.isAvailable else { return } let normalizedEffort = effort? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() ?? "" @@ -561,11 +603,14 @@ struct ModelPickerContentPane: View { let searchText: String let models: [WorkModelOption] let groupedRows: [ModelPickerRowGroup] + let providerTabs: [WorkModelProvider] + let selectedProviderTabKey: String? let currentModelId: String let currentReasoningEffort: String let favorites: [String] let isBusy: Bool let onSelect: (WorkModelOption, String?) -> Void + let onSelectProviderTab: (String) -> Void let onToggleFavorite: (String) -> Void private var favoritesSet: Set { Set(favorites) } @@ -573,6 +618,7 @@ struct ModelPickerContentPane: View { var body: some View { VStack(alignment: .leading, spacing: 0) { header + providerTabStrip Divider().overlay(ADEColor.glassBorder) if groupedRows.allSatisfy({ $0.models.isEmpty }) { emptyState @@ -611,6 +657,45 @@ struct ModelPickerContentPane: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } + @ViewBuilder + private var providerTabStrip: some View { + if providerTabs.count > 1 { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(providerTabs) { provider in + let selected = provider.key == activeProviderTabKey + Button { + onSelectProviderTab(provider.key) + } label: { + Text(provider.displayName) + .font(.caption.weight(.semibold)) + .foregroundStyle(selected ? Color.white : ADEColor.textSecondary) + .lineLimit(1) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(selected ? ADEColor.accent : ADEColor.surfaceBackground.opacity(0.55), in: Capsule()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(selected ? .isSelected : []) + } + } + .padding(.horizontal, 14) + .padding(.bottom, 8) + } + } + } + + private var activeProviderTabKey: String? { + selectedProviderTabKey + ?? providerTabs.first(where: { tab in + tab.models.contains { workModelIdsEquivalent($0.id, currentModelId) } + })?.key + ?? providerTabs.first(where: { tab in + tab.models.contains(where: { $0.isAvailable }) + })?.key + ?? providerTabs.first?.key + } + @ViewBuilder private var header: some View { HStack(alignment: .center, spacing: 8) { @@ -742,16 +827,16 @@ struct ModelPickerListRow: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - Button { - guard supportedTiers.isEmpty else { return } - onSelect(nil) + Button { + guard supportedTiers.isEmpty else { return } + onSelect(nil) } label: { headerRow .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(isBusy || !supportedTiers.isEmpty) + } + .buttonStyle(.plain) + .disabled(isBusy || !model.isAvailable || !supportedTiers.isEmpty) if !supportedTiers.isEmpty { reasoningPills(tiers: supportedTiers) @@ -762,7 +847,7 @@ struct ModelPickerListRow: View { .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(isActive ? ADEColor.accent.opacity(0.08) : ADEColor.surfaceBackground.opacity(0.55)) + .fill(isActive ? ADEColor.accent.opacity(0.08) : ADEColor.surfaceBackground.opacity(model.isAvailable ? 0.55 : 0.32)) ) .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) @@ -777,9 +862,9 @@ struct ModelPickerListRow: View { WorkProviderLogo(provider: model.provider, size: 28) VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { - Text(model.displayName) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) + Text(model.displayName) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(model.isAvailable ? ADEColor.textPrimary : ADEColor.textMuted) .lineLimit(1) if isActive { Text("active") @@ -798,9 +883,9 @@ struct ModelPickerListRow: View { .foregroundStyle(workModelTierTint(model.tier)) Text("·") .foregroundStyle(ADEColor.textMuted) - Text(model.tagline) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) + Text(model.tagline) + .font(.caption) + .foregroundStyle(model.isAvailable ? ADEColor.textSecondary : ADEColor.textMuted) .lineLimit(1) } } @@ -864,7 +949,7 @@ struct ModelPickerListRow: View { ) } .buttonStyle(.plain) - .disabled(isBusy) + .disabled(isBusy || !model.isAvailable) .accessibilityLabel("\(model.displayName) · reasoning \(reasoningLabel(for: tier))") .accessibilityAddTraits(isActiveTier ? .isSelected : []) } diff --git a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift index 6473a33e2..f1305c711 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift @@ -48,7 +48,7 @@ extension WorkSessionSettingsSheet { let reasoningPayload = normalizedReasoning.isEmpty ? "" : normalizedReasoning let reasoningChanged = reasoningPayload != resolvedInitialReasoningEffort let effectiveCodexFastMode = supportsCodexFastModeToggle ? selectedCodexFastMode : false - let codexFastModeChanged = summary.provider == "codex" && effectiveCodexFastMode != resolvedInitialCodexFastMode + let codexFastModeChanged = effectiveCodexFastMode != resolvedInitialCodexFastMode let initialRuntimeMode = workInitialRuntimeMode(summary) let initialCursorModeId = workInitialCursorModeId(summary) diff --git a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift index de9b9d760..372baf9be 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift @@ -61,7 +61,7 @@ struct WorkSessionSettingsSheet: View { } var supportsCodexFastModeToggle: Bool { - summary.provider == "codex" && (selectedModel?.supportsCodexFastMode == true) + selectedModel?.supportsCodexFastMode == true } var runtimeOptions: [WorkRuntimeOption] { diff --git a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift index 173da6f2e..587336840 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -58,6 +58,8 @@ func providerLabel(_ provider: String) -> String { case "opencode": return "OpenCode" case "cursor": return "Cursor" case "droid": return "Droid" + case "ollama": return "Ollama" + case "lmstudio": return "LM Studio" default: return provider.capitalized } } @@ -129,6 +131,10 @@ func providerTint(_ provider: String?) -> Color { return .indigo case "droid": return .gray + case "ollama": + return .green + case "lmstudio": + return .orange case "google": return .yellow case "factory": diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a9b256e25..568a7ac13 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -386,7 +386,13 @@ ade.memory.* # memory CRUD, search, health, embeddings ade.missions.* / ade.orchestrator.* ade.cto.* # identity, core memory, agent roster, Linear ade.sessions.* # terminal session CRUD -ade.agentChat.* # agent chat sessions, model inventory, parallel launch state +ade.agentChat.* # agent chat sessions, model inventory, parallel launch state. + # Includes ade.agentChat.modelCatalog (provider-grouped catalog + # used by desktop + TUI + iOS ModelPickers; accepts + # `{ mode: "cached"|"refresh-stale"|"force", refreshProvider?: "opencode"|"cursor"|"droid"|"lmstudio"|"ollama" }`). +ade.ai.* # AI integration status + provider auth (storeApiKey/deleteApiKey/getStatus/...). + # ade.ai.isOpenCodeInstalled is a cheap probe (no runtime spin-up) + # used to gate the ModelPicker OpenCode rail + Settings install CTA. ade.ai.cursorCloud.* # Cursor background-agents bridge: listRepositories, listAgents, listRuns, getAgent, createRun, followUp, streamRun, cancelRun, archiveAgent / unarchiveAgent / deleteAgent, listArtifacts / downloadArtifact, openChat (mirror an existing cloud agent into an ADE chat session) ade.automations.* ade.processes.* / ade.tests.* # processes also expose group bulk ops: diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index d5176ee66..249038bca 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -26,7 +26,7 @@ Point Cursor’s browser inspector at the served page for layout debugging. The | `apps/ade-cli/src/tuiClient/app.tsx` | Primary Ink/React surface: navigation, composer, drawers, right pane, session lifecycle, slash command dispatch. | | `apps/ade-cli/src/tuiClient/connection.ts` | Resolves attached vs embedded mode, runs the `ade/initialize` handshake, registers the project with `projects.add`, wraps subsequent requests with `projectId`. | | `apps/ade-cli/src/tuiClient/jsonRpcClient.ts` | Socket client: connect, request/response, `chat/event` notifications. | -| `apps/ade-cli/src/tuiClient/adeApi.ts` | Typed wrappers over `AdeCodeConnection.action` / `actionList` for lanes, chat, models, navigation, provider readiness, API-key status, OpenCode diagnostics, project slash-command discovery, lane diff stats (`listLaneDiffStats`), per-lane PR summaries (`listPrsByLane`), and the Claude steer family (`steerChatMessage`, `cancelSteerMessage`, `editSteerMessage`, `dispatchSteerMessage`). | +| `apps/ade-cli/src/tuiClient/adeApi.ts` | Typed wrappers over `AdeCodeConnection.action` / `actionList` for lanes, chat, models, navigation, provider readiness, API-key status, OpenCode diagnostics, project slash-command discovery, lane diff stats (`listLaneDiffStats`), per-lane PR summaries (`listPrsByLane`), the Claude steer family (`steerChatMessage`, `cancelSteerMessage`, `editSteerMessage`, `dispatchSteerMessage`), the provider-grouped model catalog (`getModelCatalog(args?: AgentChatModelCatalogArgs)` → `AgentChatModelCatalog`), and the cross-surface model-picker favorites / recents (`getModelPickerFavorites`, `toggleModelPickerFavorite`, `getModelPickerRecents`, `pushModelPickerRecent`) backed by the top-level `modelPicker.*` JSON-RPC methods on `adeRpcServer`. | | `apps/ade-cli/src/tuiClient/commands.ts` / `linearCommands.ts` | Slash command catalog and routing. | | `apps/ade-cli/src/tuiClient/format.ts` | Transcript rendering helpers for the TUI. | | `apps/ade-cli/src/tuiClient/aggregate.ts` | Pure derivations on top of the chat event stream. Produces `AggregatedBlock`s (assistant text, tool-calls / files-changed / plan / memory / compaction groups, runtime-activity rows for subagent and activity envelopes, queued steers) and `derivePendingSteers`, consumed by `ChatView` and the right-pane steer view. | @@ -50,7 +50,9 @@ Point Cursor’s browser inspector at the served page for layout debugging. The | `apps/ade-cli/src/tuiClient/components/` | `AdeWordmark`, `Drawer` (`visibleDrawerLaneCount` / `visibleDrawerChatCount`, `DrawerPrSummary` rows, lanes mode chat preview under the selected lane), `ChatView` (transcript renderer; exports `renderChatVisibleSelectionRows` / `renderChatSelectableRowTexts` / `selectedTextFromChatRows` for the ADE-owned mouse selection, plus `computeChatScrollMaxOffset` and `renderChatTranscriptPlainText`), `Header`, `RightPane` (`computeLaneChatCounts`, `LANE_DETAIL_PR_ACTION_INDEX`, wireframe `lane-details` STATUS/CHANGES/ACTIONS/PR/CHATS sections, Chat Info `chat-info`, `model-setup`), `SlashPalette`, `MentionPalette`, `ApprovalPrompt`, `ModelStatus`, `FooterControls`, and `TerminalPane` (xterm-headless preview pane that consumes `ChatTerminalPreviewResult` from `ade.terminal.preview` plus live `ade.pty.data` chunks to render a real terminal grid inside Ink; running Claude terminals can be put into direct control mode from the TUI). | | `apps/ade-cli/src/tuiClient/keybindings/index.ts` | Verbatim `~/.claude/keybindings.json` reader and TUI action dispatcher (chord support, vim namespace, clipboard-image paste hooks). Resolves `defaultKeybindingsPath()`, parses the Claude keybindings schema, and maps key sequences onto TUI actions. | | `apps/ade-cli/src/tuiClient/statusline/index.ts` | Claude-compatible status line config reader and runner. Reads the `~/.claude/statusline.json` contract, executes the configured status command, and exposes the rendered lines to `ModelStatus`. | -| `apps/desktop/src/shared/types/chat.ts` | Canonical chat DTOs (`AgentChatEventEnvelope`, sessions, pending input, `AgentChatContextUsage`, `AgentChatClaudeOutputStyle`, `AgentChatClaudePlugin`, subagent kinds). Imported per-module so ade-cli typecheck stays scoped. | +| `apps/ade-cli/src/tuiClient/components/ModelPicker/` | Ink ModelPicker pane: `ModelPickerPane.tsx` (rail + search + model rows), `modelPickerLayout.ts` (pure derivations — imports `modelOrdering` and `modelPickerSearch` from the desktop package so behaviour stays in lockstep with the renderer), and `types.ts` (`ModelPickerEntry`, `ModelPickerRailEntry`, `ModelPickerState`, plus `AdeCodeProvider` extensions for `ollama` / `lmstudio`). Reads the provider-grouped catalog via `getModelCatalog` and the favorites / recents via the cross-surface `modelPicker.*` store. | +| `apps/ade-cli/src/services/modelPickerStore.ts` | Cross-surface (desktop + TUI + iOS) favorites and recents persisted at `~/.ade/modelPicker.json`. Schema is `{ version, favorites: string[], recents: string[] }`; `MAX_RECENTS` caps the recents list. Exposed through the top-level `modelPicker.getFavorites` / `setFavorites` / `toggleFavorite` / `getRecents` / `pushRecent` JSON-RPC methods on `adeRpcServer`. | +| `apps/desktop/src/shared/types/chat.ts` | Canonical chat DTOs (`AgentChatEventEnvelope`, sessions, pending input, `AgentChatContextUsage`, `AgentChatClaudeOutputStyle`, `AgentChatClaudePlugin`, subagent kinds, `AgentChatModelCatalog*`). Imported per-module so ade-cli typecheck stays scoped. | | `apps/desktop/src/shared/modelRegistry.ts` | Default model selection for new sessions (`getDefaultModelDescriptor`). | | `apps/desktop/src/shared/adeLayout.ts` | Resolves project-scoped `.ade` paths. | diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index c1ed0baa9..347bb3d9c 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -23,12 +23,20 @@ machinery layered on top. | `apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts` | Discovers project and user Claude slash surfaces by walking ancestor `.claude` roots, reading `.claude/commands/**/*.md`, `~/.claude/commands/**/*.md`, and `.claude/skills/*/SKILL.md` / `~/.claude/skills/*/SKILL.md` entries with command frontmatter. Consumed by `agentChatService` to enrich both the `chat.slashCommands` response and Claude system prompt with local command/skill metadata. | | `apps/desktop/src/main/services/chat/chatTextBatching.ts` | Batches streaming assistant text fragments (100 ms) before emission to reduce renderer re-renders. | | `apps/desktop/src/main/services/chat/sessionRecovery.ts` | Version-2 persisted-state reconstruction when sessions resume from disk. | -| `apps/desktop/src/main/services/chat/cursorSdkPool.ts` | Cursor SDK adapter: spawns and pools `cursorSdkWorker.ts` Node workers per session, sends turns, brokers permission/hook callbacks, maps SDK events to chat events, and handles teardown. | +| `apps/desktop/src/main/services/chat/cursorSdkPool.ts` | Cursor SDK adapter: spawns and pools `cursorSdkWorker.ts` Node workers per session, sends turns, brokers permission/hook callbacks, maps SDK events to chat events, and handles teardown. The connection envelope now carries `modelParams` (a list of `CursorSdkModelParameterValue`s — e.g. `{ id: "reasoning", value: "high" }`) so per-model variant parameters discovered through `cursorModelsDiscovery` flow into the SDK boot. | | `apps/desktop/src/main/services/chat/cursorSdkWorker.ts` | Node worker that hosts the official `@cursor/sdk` and bridges it to the main process via the JSON line protocol in `cursorSdkProtocol.ts`. | -| `apps/desktop/src/main/services/chat/cursorSdkProtocol.ts` | Shared types for the worker IPC: chat mode, approval policy, sandbox mode, hook decisions, hook requests, and `CursorSdkWorkerInit` boot envelope. | +| `apps/desktop/src/main/services/chat/cursorSdkProtocol.ts` | Shared types for the worker IPC: chat mode, approval policy, sandbox mode, hook decisions, hook requests, `CursorSdkModelParameterValue`, and `CursorSdkWorkerInit` boot envelope. | | `apps/desktop/src/main/services/chat/cursorSdkPolicy.ts` | Maps ADE permission modes onto Cursor SDK chat mode + approval policy + sandbox mode (`ade` / `cursor-native` / `off`); decides which tool calls auto-approve and which require a user prompt. | | `apps/desktop/src/main/services/chat/cursorSdkSystemPrompt.ts` | Builds the system prompt the Cursor worker injects (lane context, ADE CLI guidance, persona overlays). | | `apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts` | Translates `@cursor/sdk` stream events into the ADE `AgentChatEventEnvelope` shape consumed by the renderer. | +| `apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts` | Probes the live `@cursor/sdk` for model rows; reads `parameters[]` + `variants[]` and classifies per-parameter values into `reasoningTiers` (`none`/`dynamic`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`/`thinking`) and `serviceTiers` (`fast`). `resolveCursorSdkModelSelectionParams` rebuilds the matching `CursorSdkModelParameterValue[]` so the SDK boot can target the right variant. The previous minimal `auto` / `composer-2` fallback list has been removed. | +| `apps/desktop/src/main/services/chat/droidSdkPool.ts` | Droid SDK adapter. Forks `droidSdkWorker.cjs` per session, exposes `acquireDroidSdkConnection` / `releaseDroidSdkConnection`, and proxies prompt sends, settings updates, permission decisions, ask-user responses, and cancellation through the worker. Resolves the Droid SDK CLI executable via `resolveDroidExecutable` (PATH + bundle + configured install paths). | +| `apps/desktop/src/main/services/chat/droidSdkWorker.ts` | Node worker that hosts `@factory/droid-sdk`. Streams SDK events back to the main process and forwards permission / ask-user prompts back through the JSON-line protocol. | +| `apps/desktop/src/main/services/chat/droidSdkProtocol.ts` | Worker IPC types: `DroidSdkSessionSettings` (autonomy level, interaction mode, reasoning effort), `DroidSdkReasoningEffort`, `DroidSdkPermissionRequest`/`Decision`, `DroidSdkAskUserRequest`/`Response`, `DroidSdkReady` (handshake with `availableModels`), and `DroidSdkSendPrompt`. | +| `apps/desktop/src/main/services/chat/droidSdkEventMapper.ts` | Per-session `DroidSdkEventMapperState` + `mapDroidSdkMessageToChatEvents` / `mapDroidSdkRunResultToDoneEvent`. Tracks streaming text/thinking item ids, maps tool calls and results, and surfaces token usage. Replaces the deleted `droidAcpPool.ts` + `droidAcpEventMapper` path. | +| `apps/desktop/src/main/services/chat/droidModelsDiscovery.ts` | SDK-driven model probe (`listDroidModelsFromSdk`) plus the `~/.factory/config.json` custom-proxy merge. Exposes `discoverDroidSdkModelDescriptors` (alias for the legacy `discoverDroidCliModelDescriptors` while callers migrate). | +| `apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts` | Resolves the OpenCode CLI: PATH first, then the bundled `node_modules/.bin/opencode`. Cache entries are re-validated with `canRunBinaryCandidate` on every lookup so user installs after launch are picked up; missing-binary lookups are intentionally not cached. `clearOpenCodeBinaryCache()` is wired into the AI integration's full cache reset. | +| `apps/desktop/src/main/services/opencode/openCodeInventory.ts` | OpenCode provider/model probe. Now classifies model variants into `reasoningTiers` + `serviceTiers` (alias map covering `minimal`/`mini`/`med`/`xhigh`/`extra-high`), reads `capabilities` (tools/vision/reasoning) into descriptor capabilities, and tracks both `modelIds` (connected providers only) and `catalogModelIds` (the full browseable catalog). `OpenCodeProviderInfo.availableModelCount` exposes the connected count separately from `modelCount`. | | `apps/desktop/src/shared/chatTranscript.ts` | Pure JSON-lines parser for `AgentChatEventEnvelope` values. Used by both the main process and the renderer. | | `apps/desktop/src/shared/types/chat.ts` | All chat types: `AgentChatSession`, `AgentChatEvent` union, `AgentChatEventHistorySnapshot` (with optional `sessionFound` for stale-session detection), permission modes, pending input, completion reports, `PARALLEL_CHAT_MAX_ATTACHMENTS`, and parallel launch state DTOs. | | `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Top-level renderer surface: state derivation, IPC wiring, composer mount, message-list mount, End/Delete chat controls in the header, parallel multi-model lane launch orchestration, transient-lane cleanup, and multi-lane deep-link navigation. Mounts `AgentQuestionModal` when the active pending input is a question/structured-question. Resolves the surface accent colour through `providerChatAccent(provider)` so Claude/Codex/Cursor stay visually consistent regardless of model variant. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts when IPC misses an event, even when the tile is not focused. Event-history snapshots with `sessionFound: false` clear stale locked-pane state instead of rendering a dead transcript. During project transitions the pane blocks send/model/permission mutations and shows a "Project is switching..." composer placeholder so chat calls do not hit the wrong runtime binding. On macOS, polls `ade.iosSimulator.getStatus` and renders the iOS Simulator drawer toggle in the header when the platform is supported (see [iOS Simulator feature](../ios-simulator/README.md)); selecting elements inside the drawer flows back through the pane as `IosElementContextItem` chips on the composer. Polls `ade.appControl.getStatus` and exposes the App Control drawer toggle when the platform is supported, mounting `ChatAppControlPanel`; selections become `AppControlContextItem` chips + attachments on the composer. See [App Control](../computer-use/app-control.md). When mounted as a Work tile (`SessionSurface` passes `hideLaneToolDrawers={true}`) the iOS, App Control, and chat terminal drawer toggles are suppressed because the Work right-edge sidebar owns those lane-scoped drawers; hidden lane-tool mode also skips App Control status polling and terminal listing. The pane still listens on `ade:agent-chat:add-attachment` / `add-ios-context` / `add-app-control-context` / `add-builtin-browser-context` / `insert-draft` window events so selections from the sidebar flow into the active chat composer when the sidebar's `attachChatSessionId` matches. Work-tab CLI launches pass the active lane worktree into the shared launcher so the spawned CLI sees lane-aware ADE skill roots. Proof remains chat-scoped and stays on the chat header. | @@ -85,16 +93,19 @@ render them, but neither one *runs* them. rewindFiles, forkSession, and output-style selection all run through the SDK control channel surfaced on the active `Query` handle. - **Provider-agnostic sessions.** `AgentChatProvider` is one of `claude`, - `codex`, `opencode`, `cursor`, or a free-form string reserved for local - providers. The service owns a pluggable adapter per provider (Claude Agent - SDK query stream, Codex JSON-RPC app-server, OpenCode runtime, Cursor SDK pool via - `cursorSdkPool.ts`). The Cursor adapter runs the official `@cursor/sdk` - in a Node worker (`cursorSdkWorker.ts`) over the JSON line protocol - defined in `cursorSdkProtocol.ts`; permissions, hooks, and the system - prompt are assembled by `cursorSdkPolicy.ts` and - `cursorSdkSystemPrompt.ts`, and SDK events are translated into - ADE chat events by `cursorSdkEventMapper.ts`. Cursor is SDK-backed here; - ACP is only used by providers that still expose an ACP host, such as Droid. + `codex`, `opencode`, `cursor`, `droid`, or a free-form string reserved for + local providers. The service owns a pluggable adapter per provider (Claude + Agent SDK query stream, Codex JSON-RPC app-server, OpenCode runtime, Cursor + SDK pool via `cursorSdkPool.ts`, Droid SDK pool via `droidSdkPool.ts`). Both + Cursor and Droid run their official SDKs (`@cursor/sdk`, `@factory/droid-sdk`) + in dedicated Node worker children over JSON-line protocols + (`cursorSdkProtocol.ts`, `droidSdkProtocol.ts`). ADE owns permissions, + hooks, ask-user prompts, and the system prompt; the SDK owns model + tool + execution. SDK events are translated to ADE chat events by + `cursorSdkEventMapper.ts` / `droidSdkEventMapper.ts`. The previous + ACP-based Droid bridge (`droidAcpPool.ts` / `acpEventMapper`) has been + retired — only `mapStopReasonToTerminalEvents` is still imported from + `acpEventMapper.ts` for terminal lifecycle parity. - **Lane-scoped.** Every session carries `laneId`; lane context (branch, worktree path) is injected into the system prompt, and working-directory resolution runs through `resolveLaneLaunchContext`. @@ -298,6 +309,7 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`. | `ade.agentChat.saveTempAttachment` | invoke | Write pasted/dropped image bytes to a temp file (10 MB cap). Native clipboard image paste prefers `ade.app.saveClipboardImageAttachment` so Electron can save the clipboard PNG directly and return a compact preview. | | `ade.agentChat.listSubagents` | invoke | Claude subagent snapshot list. Snapshots are re-keyed on `agentId + parentToolUseId` (not just `taskId`) so multiple subagents spawned from the same parent tool call don't collide, and the renderer panel separates them into three tabs: Subagents, Teammates, and Background. | | `ade.agentChat.models` | invoke | `{ provider, activateRuntime? }`. For OpenCode `activateRuntime: true` is required to *launch* a probe server; otherwise the main process only returns the cached inventory (via `peekOpenCodeInventoryCache`) and an empty list until a real probe has been run. The renderer cache (`aiDiscoveryCache.ts`) keys on `(projectRoot, provider, activateRuntime)` so passive and active reads don't collide. | +| `ade.agentChat.modelCatalog` | invoke | `{ mode?, refreshProvider? }` → `AgentChatModelCatalog`. Returns the full provider-grouped catalog (claude / codex / cursor / droid / opencode plus the local `ollama` / `lmstudio` groups when OpenCode-routed) for the desktop and TUI ModelPickers. `mode: "cached"` returns the in-memory snapshot, `"refresh-stale"` reuses the cache but optionally re-probes the named runtime when its per-provider freshness TTL is expired, and `"force"` re-probes unconditionally. `refreshProvider` is one of `"opencode" | "cursor" | "droid" | "lmstudio" | "ollama"`. The catalog carries an optional `stale: true` flag and per-model `connected` / `requiresConfiguration` / `sourceRuntime` / `providerId` / `providerName` annotations that the renderer/TUI use to render auth gates. | | `ade.agentChat.getSessionCapabilities` | invoke | Discover supported subagent/review features. | | `ade.agentChat.getTurnFileDiff` | invoke | Lazy diff expansion for a turn-file-summary row. | | `ade.agentChat.event` | push | Stream of `AgentChatEventEnvelope` into the renderer. | @@ -383,6 +395,25 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`. request key in `availableModelsRequests` is `${provider}:${mode}` so an active probe and a passive peek can be in flight concurrently without cross-resolving. +- **OpenCode binary gating.** `ade.ai.isOpenCodeInstalled` is a cheap + IPC (no probe, just a `resolveOpenCodeBinary` lookup) used by the + ModelPicker / Settings to gate the OpenCode rail and surface an + "Install OpenCode" CTA without flashing before auth/install status + loads. `openCodeBinaryManager.resolveOpenCodeBinary` re-validates the + cached path on every call (so a fresh user install during the same + session is picked up) and intentionally does not cache misses. + `clearOpenCodeBinaryCache()` is wired into the AI integration's full + cache reset alongside `clearOpenCodeInventoryCache` and the dynamic + descriptor reset. +- **OpenCode inventory cache shape.** `probeOpenCodeProviderInventory` + returns `{ modelIds, catalogModelIds, providers, error, descriptors }`. + `modelIds` is the selectable list (connected providers only); + `catalogModelIds` is the full browseable catalog including unconnected + cloud providers. `OpenCodeProviderInfo` carries both `modelCount` and + `availableModelCount`. Variant keys are classified into + `reasoningTiers` (alias map handles `mini`/`med`/`extra-high`/etc.) + and `serviceTiers` (`fast`) instead of a flat `variantKeys` array; the + Settings page UI consumes this when drawing per-provider model rails. - **OpenCode shared server pool compaction.** Acquiring a shared OpenCode server (`acquireSharedOpenCodeServer`) now calls `pruneIdleSharedEntries(excludeKey)` which shuts down every other diff --git a/docs/features/chat/agent-routing.md b/docs/features/chat/agent-routing.md index 8ef7d7b0b..99a719dfc 100644 --- a/docs/features/chat/agent-routing.md +++ b/docs/features/chat/agent-routing.md @@ -15,8 +15,10 @@ where the machinery lives. | `apps/desktop/src/main/services/ai/providerRuntimeHealth.ts` | Tracks provider readiness/auth/network failures so the UI can surface degraded states. | | `apps/desktop/src/main/services/ai/providerOptions.ts` | Normalises provider-native options (Claude permission mode, Codex approval + sandbox, OpenCode permission). | | `apps/desktop/src/main/services/ai/authDetector.ts` | Discovers available credentials (CLI, API key, OAuth) and reports auth status. | -| `apps/desktop/src/main/services/ai/codexExecutable.ts` / `droidExecutable.ts` | CLI resolution for runtimes that still need an external binary (looks on PATH, in the app bundle, then in configured install paths where supported). Claude uses the bundled Claude Agent SDK binary; Cursor runs through the embedded `@cursor/sdk`. | +| `apps/desktop/src/main/services/ai/codexExecutable.ts` / `droidExecutable.ts` | CLI resolution for runtimes that still need an external binary (looks on PATH, in the app bundle, then in configured install paths where supported). Claude uses the bundled Claude Agent SDK binary; Cursor and Droid run through embedded SDKs (`@cursor/sdk`, `@factory/droid-sdk`). | | `apps/desktop/src/main/services/ai/tools/systemPrompt.ts` | Adjusts the system prompt per mode (`chat`, `coding`, `planning`) and permission mode. | +| `apps/desktop/src/main/services/chat/droidSdkPool.ts`, `droidSdkWorker.ts`, `droidSdkProtocol.ts`, `droidSdkEventMapper.ts` | Droid SDK adapter. `droidSdkPool` forks `droidSdkWorker.cjs` (one per session), brokers prompt sends, permission requests, ask-user prompts, and settings updates via the JSON-line protocol in `droidSdkProtocol`. `droidSdkEventMapper` translates Droid SDK events into the canonical `AgentChatEventEnvelope` shape; the per-session mapper state (`createDroidSdkEventMapperState`) tracks streaming text/thinking item ids, in-flight tool-use names, and the latest usage breakdown. | +| `apps/desktop/src/main/services/chat/droidModelsDiscovery.ts` | Droid model discovery: probes the live SDK via `createSession({ execPath })` to read `initResult.availableModels`, normalizes `supportedReasoningEfforts` and `tier`/`promoLabel` into `reasoningTiers`/`serviceTiers`, and emits `droid/` descriptors via `createDynamicDroidCliModelDescriptor`. Custom (`~/.factory/config.json`) models are merged in. The legacy `DROID_DEFAULT_MODEL_IDS` constant has been removed — the SDK is the only source. | ## Supported providers @@ -30,7 +32,7 @@ for vendored runtimes without changing the union. | `codex` | `codex app-server` subprocess, JSON-RPC protocol. Spawn failures surface as error events. | `agentChatService.ts` (Codex adapter); config via `codexAppServerConfig.ts`. | | `opencode` | OpenCode server runtime: Anthropic/OpenAI/Google/Mistral/DeepSeek/xAI/Groq/Together AI API keys, OpenRouter, and local (Ollama, LM Studio, vLLM). | `agentChatService.ts` (OpenCode adapter); model discovery in `localModelDiscovery.ts` and `modelsDevService.ts`. | | `cursor` | Official `@cursor/sdk` running in a Node worker pool. ADE owns permissions, hooks, and the system prompt; the SDK owns the model + tool execution. | `cursorSdkPool.ts`, `cursorSdkWorker.ts`, `cursorSdkProtocol.ts`, `cursorSdkPolicy.ts`, `cursorSdkSystemPrompt.ts`, `cursorSdkEventMapper.ts`. | -| `droid` | Factory Droid CLI models exposed as dynamic `droid/` descriptors and driven through the Droid ACP bridge. | `agentChatService.ts` (Droid adapter); model helpers in `modelRegistry.ts`. | +| `droid` | Factory Droid models exposed as dynamic `droid/` descriptors and driven through the official `@factory/droid-sdk` running in a forked Node worker pool. The legacy ACP bridge (`droidAcpPool.ts`) has been retired. | `droidSdkPool.ts`, `droidSdkWorker.ts`, `droidSdkProtocol.ts`, `droidSdkEventMapper.ts`, `droidModelsDiscovery.ts`; model helpers in `modelRegistry.ts`. | ## Model registry diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 1d7bb39d8..2f9946d9f 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -34,7 +34,8 @@ stream plus session metadata. | `CodeHighlighter.tsx`, `chatStatusVisuals.tsx`, `chatSurfaceTheme.ts`, `chatToolAppearance.tsx` | Supporting visuals. `chatStatusVisuals.ChatStatusGlyph` takes an `animate` prop so non-active rows skip the ping/spin animation; `AgentChatMessageList.ActivityIndicator` mirrors this and switches to a dimmed static tone plus a non-looping Brain lottie for `thinking` once the turn ends. | | `pendingInput.ts`, `chatExecutionSummary.ts`, `chatNavigation.ts`, `chatTranscriptRows.ts` | Pure state derivations consumed by the UI. | | `apps/desktop/src/renderer/lib/visualContextFormatting.ts` | Prompt formatting for visual/tool context. Automatic macOS VM capability context is attached only when the outgoing prompt asks for ADE VM / macOS VM / Lume / isolated macOS GUI use, unless a caller forces it. | -| `apps/desktop/src/shared/types/chat.ts` | Shared composer/session DTOs, including `PARALLEL_CHAT_MAX_ATTACHMENTS` and parallel launch state types. | +| `apps/desktop/src/shared/types/chat.ts` | Shared composer/session DTOs, including `PARALLEL_CHAT_MAX_ATTACHMENTS`, parallel launch state types, the `AgentChatModelCatalog*` set, `AgentChatModelCatalogRefreshProvider` (`opencode` / `cursor` / `droid` / `lmstudio` / `ollama`), and `AgentChatModelCatalogArgs` (`mode`, `refreshProvider`). | +| `apps/desktop/src/renderer/components/shared/ModelPicker/` | Modular ModelPicker (see [ModelPicker structure](#modelpicker-structure)): `ModelPicker.tsx`, `ModelPickerContent.tsx`, `ModelPickerRail.tsx`, `ModelListRow.tsx`, `ReasoningEffortPicker.tsx`, `modelCatalog.ts`, `modelOrdering.ts`, `modelPickerSearch.ts`, `providerEmptyState.tsx`, `runtimeCatalogCache.ts`, plus the `useProviderAuthStatus` / `useAuthOnlyFilter` / `useModelFavorites` / `useModelRecents` / `usePerSurfaceModelDefaults` / `useReasoningByFamily` hooks. | ## Pane layout @@ -120,16 +121,21 @@ and a footer that contains the composer. - **Model selection.** `ProviderModelSelector` is embedded and filters the registry via `filterChatModelIdsForSession`. Switching within the allowed family is a normal update; crossing families triggers a - handoff. The Cursor model inventory (`getAgentChatModelsCached` with - `provider: "cursor"`, `activateRuntime: true`) is no longer fetched - on chat boot — the selector exposes an `onOpen` callback that fires - the first time the user actually opens the model catalog, and - `AgentChatPane.refreshCursorModelInventory` is the only path that - performs the active probe. It also no-ops when the latest - `availableModelIds` already contains a Cursor entry, so re-opening - the catalog after a successful inventory does not refire the probe. -- **Reasoning effort.** Dropdown for models that support reasoning - tiers. + handoff. Backed by the modular `ModelPicker` under + `renderer/components/shared/ModelPicker/` (see + [ModelPicker structure](#modelpicker-structure)). Dynamic-runtime + inventories (Cursor / Droid / OpenCode / Ollama / LM Studio) are no + longer fetched on chat boot — the picker calls + `window.ade.agentChat.modelCatalog({ mode: "cached" | "refresh-stale" | + "force", refreshProvider? })` and only triggers a runtime probe when + the user actually opens the corresponding rail and the per-provider + freshness TTL has lapsed (`runtimeCatalogCache.ts`: 30 min for + Cursor / Droid / OpenCode, 30 s for `lmstudio` / `ollama`). +- **Reasoning effort.** A standalone `ReasoningEffortPicker` (extracted + from the model row) is rendered next to the model trigger when the + active descriptor exposes `reasoningTiers`. The picker remembers the + last-used effort per model family via the `useReasoningByFamily` + hook. - **Fast mode (Codex).** A yellow Lightning chip next to the model selector that toggles `codexFastMode` for the selected session. Renders only when `modelSupportsFastMode(getModelById(modelId))` @@ -233,6 +239,36 @@ and a footer that contains the composer. - `"grid-tile"` -- constrained for packed grid tiles; `composerMaxHeightPx` limits auto-grow. +### ModelPicker structure + +The desktop ModelPicker under +`apps/desktop/src/renderer/components/shared/ModelPicker/` is split into +focused modules. Each piece is independently testable; the same modules +power the TUI picker (`apps/ade-cli/src/tuiClient/components/ModelPicker/`). + +| Module | Role | +|---|---| +| `ModelPicker.tsx` | Trigger + popover entry point. Owns runtime-catalog loading via `runtimeCatalogCache`, fast-mode chip, and the favorites/recents fan-out. | +| `ModelPickerContent.tsx` | The popover body: search bar, rail, list, empty state. | +| `ModelPickerRail.tsx` | Left-rail tabs (Favorites / Recents / per-provider groups). Reads `AuthStatus` per family to render auth gates and the OpenCode "Install OpenCode" CTA from `providerEmptyState`. | +| `ModelListRow.tsx` | A single model row (favorite star, brand logo, display name, sub-provider chip, availability tone). | +| `ReasoningEffortPicker.tsx` | Standalone reasoning-effort dropdown, mounted next to the model trigger and inside per-slot parallel-launch controls. | +| `modelCatalog.ts` | `descriptorsFromAgentChatModelCatalog`, `mergeSelectorModels`, `resolveModelDescriptorWithRuntimeCatalog`, `createUnknownModelPlaceholder` — pure helpers that flatten the IPC catalog into a `ModelDescriptor[]` and reconcile it with the static registry. | +| `modelOrdering.ts` | `sortModelItems` — provider/group ordering and intra-group ranking (favorites first, then recents, then default registry order). | +| `modelPickerSearch.ts` | `scoreModelPickerSearch` — fuzzy search across display name, family, provider, and ids; ranks favorites/recents above strict matches. | +| `providerEmptyState.tsx` | Per-provider empty/auth/install CTA copy. Surfaces "Install OpenCode" when the binary is missing, "Sign in to Cursor" when auth is missing, etc. | +| `runtimeCatalogCache.ts` | Renderer-side shared catalog cache. Tracks per-provider freshness (30 min for `opencode`/`cursor`/`droid`, 30 s for `lmstudio`/`ollama`) and dedupes concurrent `modelCatalog` requests by `${mode}:${refreshProvider}` keys. | +| `useProviderAuthStatus.ts` | Resolves `AuthStatus` (`authenticated` / `missing` / `unknown`) per `ProviderFamily` from the AI integration status. | +| `useAuthOnlyFilter.ts` | Hides models whose provider is not authenticated, with a toggle for the catalog browse mode. | +| `useModelFavorites.ts` / `useModelRecents.ts` | Cross-surface favorites and recents persisted to `~/.ade/modelPicker.json` via the `modelPicker.*` JSON-RPC methods on `adeRpcServer`. The TUI shares the same store. | +| `usePerSurfaceModelDefaults.ts` | Per-surface default-model resolver (Settings, parallel slots, mission planning, etc.) — keyed by surface so each call site can have its own remembered default. | +| `useReasoningByFamily.ts` | Last-used reasoning effort per model family. | + +Renderer state and the TUI share descriptors and ordering: the TUI +`ModelPicker/modelPickerLayout.ts` imports +`modelPickerSearch`/`modelOrdering` from the desktop package directly, +so behaviour stays in lockstep. + ### Attachment handling - Pasted and dropped images are written to a temp location. File-backed diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index 32344a171..dac650f33 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -143,7 +143,15 @@ uses the selected parent lane's current branch. - `listSessions`, `getSummary`, `getTranscript` - `create`, `send`, `interrupt`, `steer`, `cancelSteer`, `editSteer`, `dispatchSteer`, `cancelDispatchedSteer`, `approve`, `respondToInput` -- `restart`, `updateSession`, `archive`, `unarchive`, `delete`, `models` +- `restart`, `updateSession`, `archive`, `unarchive`, `delete`, `models`, + `modelCatalog` + +`chat.modelCatalog` accepts `{ mode?, refreshProvider? }` where `mode` +is `"cached" | "refresh-stale" | "force"` (default `"cached"`) and +`refreshProvider` is `"opencode" | "cursor" | "droid" | "lmstudio" | +"ollama"`. The host returns the full provider-grouped catalog used by +the desktop and TUI ModelPickers and the iOS Work model sheet; only +explicit `force` / `refresh-stale` calls trigger a runtime probe. `chat.dispatchSteer` (Claude SDK only) takes `{ sessionId, steerId, mode: "inline" | "interrupt" }` and either folds From 34115acd87d521e78caa53ea121207c719e64d27 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 18 May 2026 18:11:12 -0400 Subject: [PATCH 14/14] =?UTF-8?q?ship:=20iter=201=20=E2=80=94=20fix=20test?= =?UTF-8?q?-desktop(8)=20timeout,=20address=2013=20CodeRabbit=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI fix: - aiOrchestratorService.test.ts: bump health-sweep test deadline 180s→280s and vitest timeout 240s→300s; CI shard 8 hit the ceiling, logic is correct (passes in ~4s locally) Review fixes (CodeRabbit): - modelPickerStore: flush before resetSharedModelPickerStoreForTests - tuiClient/app.tsx: sticky model picker pane + catalog-aware provider resolution - modelPickerLayout: stale-provider fallback before deriving railIndex/pool - droidSdkEventMapper: kind-aware itemId fallback for dedupe (text/thinking) - droidSdkWorker: unique waiter IDs + per-run AbortController set - preload: extend not-callable fallback to sync calls (modelPicker.*) - AgentChatPane: thread runtime-catalog descriptor through draft provider paths - ModelPickerContent: handle clipboard rejection via .catch - useModelFavorites / useModelRecents: retry hydration after RPC failure - shared/modelCatalog: classify direct local models into ollama/lmstudio 8 comments deferred with documented reasons (P2 P-fix, iOS Swift, invasive refactor scope) — recorded in shipLane state. --- apps/ade-cli/src/services/modelPickerStore.ts | 7 +++++ apps/ade-cli/src/tuiClient/app.tsx | 5 ++- .../ModelPicker/modelPickerLayout.ts | 31 ++++++++++++++----- .../main/services/chat/droidSdkEventMapper.ts | 3 +- .../src/main/services/chat/droidSdkWorker.ts | 31 +++++++++++++------ .../aiOrchestratorService.test.ts | 7 +++-- apps/desktop/src/preload/preload.ts | 4 ++- .../components/chat/AgentChatPane.tsx | 4 +-- .../shared/ModelPicker/ModelPickerContent.tsx | 4 ++- .../shared/ModelPicker/useModelFavorites.ts | 8 ++--- .../shared/ModelPicker/useModelRecents.ts | 8 ++--- apps/desktop/src/shared/modelCatalog.ts | 2 +- 12 files changed, 77 insertions(+), 37 deletions(-) diff --git a/apps/ade-cli/src/services/modelPickerStore.ts b/apps/ade-cli/src/services/modelPickerStore.ts index 4a89a5e8f..41bc2beac 100644 --- a/apps/ade-cli/src/services/modelPickerStore.ts +++ b/apps/ade-cli/src/services/modelPickerStore.ts @@ -151,5 +151,12 @@ export function getSharedModelPickerStore(): ModelPickerStore { return sharedStoreInstance; } export function resetSharedModelPickerStoreForTests(): void { + if (sharedStoreInstance) { + try { + sharedStoreInstance.flush(); + } catch { + // best-effort flush during teardown + } + } sharedStoreInstance = null; } diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 01cb1c7c6..a6cd53664 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -3762,6 +3762,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }); setRightOpen(true); setPaneFocus("details"); + lastUserOpenedPaneRef.current = "model-picker"; void refreshAiSetupStatus().catch(() => undefined); void loadProviderModels(provider, { applyDefault: false }).catch(() => undefined); if (provider === "opencode" || provider === "cursor" || provider === "droid" || provider === "lmstudio" || provider === "ollama") { @@ -6026,12 +6027,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const commitModelPickerSelection = useCallback( (modelId: string) => { let catalogModel: AgentChatModelCatalogModel | null = null; + let catalogProvider: AdeCodeProvider | null = null; for (const group of modelCatalogRef.current?.groups ?? modelCatalog?.groups ?? []) { for (const provider of group.providers) { for (const subsection of provider.subsections) { const found = subsection.models.find((entry) => entry.id === modelId || entry.modelId === modelId); if (found) { catalogModel = found; + catalogProvider = normalizeProvider(group.key as AdeCodeProvider); break; } } @@ -6048,7 +6051,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const descriptor = getModelById(modelId); const provider: AdeCodeProvider = descriptor ? normalizeProvider(resolveProviderGroupForModel(descriptor)) - : modelState.provider; + : catalogProvider ?? modelState.provider; applyModelState((prev) => ({ ...prev, ...modelStatePatchForModel(provider, target), diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts index c7ae9e692..8e8759af7 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts @@ -143,28 +143,43 @@ export function buildModelPickerLayout(input: BuildLayoutInput): ModelPickerStat const trimmedQuery = input.query.trim(); const searchActive = trimmedQuery.length > 0; + // Stale provider selections (persisted from a prior session where the provider + // had entries) can fall through to an empty pool while railIndex defaults + // back to favorites — leaving the rail and pool out of sync. Normalize the + // selection up-front so both derive from the same authoritative state. + let normalizedSelection = input.selection; + if ( + !searchActive + && normalizedSelection.kind === "provider" + && !providersPresent.includes(normalizedSelection.provider) + ) { + normalizedSelection = providersPresent.length + ? { kind: "provider", provider: providersPresent[0]! } + : { kind: "favorites" }; + } + let pool: ModelPickerEntry[]; if (searchActive) { pool = allEntries; - } else if (input.selection.kind === "favorites") { + } else if (normalizedSelection.kind === "favorites") { pool = allEntries.filter((entry) => favoritesSet.has(entry.modelId)); - } else if (input.selection.kind === "recents") { + } else if (normalizedSelection.kind === "recents") { const recentSet = new Set(input.recents); const order = new Map(input.recents.map((id, i) => [id, i] as const)); pool = allEntries .filter((entry) => recentSet.has(entry.modelId)) .sort((a, b) => (order.get(a.modelId) ?? 0) - (order.get(b.modelId) ?? 0)); } else { - const target = input.selection.provider; + const target = normalizedSelection.provider; pool = allEntries.filter((entry) => entry.family === target); } const providerTabs = (() => { - if (searchActive || input.selection.kind !== "provider") return []; + if (searchActive || normalizedSelection.kind !== "provider") return []; const groups = new Map(); for (const entry of pool) { const key = entry.subProviderKey || entry.subProvider || "__default__"; - const label = entry.subProvider || providerLabel(input.selection.provider); + const label = entry.subProvider || providerLabel(normalizedSelection.provider); const existing = groups.get(key); if (existing) { existing.entries.push(entry); @@ -223,12 +238,12 @@ export function buildModelPickerLayout(input: BuildLayoutInput): ModelPickerStat // Pick rail index from selection. let railIndex = 0; - if (input.selection.kind === "favorites") { + if (normalizedSelection.kind === "favorites") { railIndex = 0; - } else if (input.selection.kind === "recents") { + } else if (normalizedSelection.kind === "recents") { railIndex = 1; } else { - const targetProvider = input.selection.provider; + const targetProvider = normalizedSelection.provider; const idx = railEntries.findIndex( (entry) => entry.kind === "provider" && entry.provider === targetProvider, ); diff --git a/apps/desktop/src/main/services/chat/droidSdkEventMapper.ts b/apps/desktop/src/main/services/chat/droidSdkEventMapper.ts index 7ed4dc169..45e7621de 100644 --- a/apps/desktop/src/main/services/chat/droidSdkEventMapper.ts +++ b/apps/desktop/src/main/services/chat/droidSdkEventMapper.ts @@ -134,7 +134,8 @@ export function mapDroidSdkMessageToChatEvents( const role = readString(record.role); if (role !== "assistant") return []; return extractTextBlocks(record.content).flatMap((block, index): AgentChatEvent[] => { - const itemId = block.id ?? `${readString(record.messageId) ?? "droid-message"}:${block.kind}:${index}`; + const messageId = readString(record.messageId) ?? `droid-${block.kind === "thinking" ? "thinking" : "text"}`; + const itemId = block.id ?? `${messageId}:${block.kind}:${index}`; if (block.kind === "thinking") { if (meta.state.thinkingDeltaItemIds.has(itemId)) return []; return [{ type: "reasoning", text: block.text, itemId, turnId }]; diff --git a/apps/desktop/src/main/services/chat/droidSdkWorker.ts b/apps/desktop/src/main/services/chat/droidSdkWorker.ts index 2925ee402..b8c2294a2 100644 --- a/apps/desktop/src/main/services/chat/droidSdkWorker.ts +++ b/apps/desktop/src/main/services/chat/droidSdkWorker.ts @@ -18,10 +18,16 @@ type DroidSession = Awaited>; let sdkModule: DroidSdkModule | null = null; let initState: DroidSdkWorkerInit | null = null; let session: DroidSession | null = null; -let currentAbort: AbortController | null = null; +const activeAborts = new Set(); +let waiterSeq = 0; const permissionWaiters = new Map void>(); const askUserWaiters = new Map void>(); +function nextWaiterId(prefix: string): string { + waiterSeq = (waiterSeq + 1) >>> 0; + return `${prefix}-${Date.now()}-${waiterSeq}`; +} + function post(message: DroidSdkWorkerResponse): void { if (process.send) process.send(message); } @@ -103,11 +109,13 @@ async function requestPermission( params: DroidSdkTypes.RequestPermissionRequestParams, ): Promise { const request = summarizePermission(params); + const waiterId = nextWaiterId("droid-permission"); + const requestWithId = { ...request, id: waiterId }; const decision = await new Promise((resolve) => { - permissionWaiters.set(request.id, resolve); - post({ type: "permission_request", requestId: request.id, request }); + permissionWaiters.set(waiterId, resolve); + post({ type: "permission_request", requestId: waiterId, request: requestWithId }); }); - permissionWaiters.delete(request.id); + permissionWaiters.delete(waiterId); return { selectedOption: decision.selectedOption as DroidSdkTypes.RequestPermissionSelection, ...(decision.comment?.trim() ? { comment: decision.comment.trim() } : {}), @@ -132,11 +140,13 @@ function summarizeAskUser(params: DroidSdkTypes.AskUserRequestParams): DroidSdkA async function requestAskUser(params: DroidSdkTypes.AskUserRequestParams): Promise { const request = summarizeAskUser(params); + const waiterId = nextWaiterId("droid-ask-user"); + const requestWithId = { ...request, id: waiterId }; const response = await new Promise((resolve) => { - askUserWaiters.set(request.id, resolve); - post({ type: "ask_user_request", requestId: request.id, request }); + askUserWaiters.set(waiterId, resolve); + post({ type: "ask_user_request", requestId: waiterId, request: requestWithId }); }); - askUserWaiters.delete(request.id); + askUserWaiters.delete(waiterId); return response as DroidSdkTypes.AskUserResult; } @@ -226,7 +236,7 @@ async function sendPrompt(payload: DroidSdkWorkerRequest & { type: "send" }): Pr if (!session || !initState) throw new Error("Droid SDK worker is not initialized."); await applySettings(payload.payload.settings); const controller = new AbortController(); - currentAbort = controller; + activeAborts.add(controller); let tokenUsage: unknown = null; let firstError: unknown = null; try { @@ -253,7 +263,7 @@ async function sendPrompt(payload: DroidSdkWorkerRequest & { type: "send" }): Pr ...(firstError ? { error: firstError } : {}), }; } finally { - if (currentAbort === controller) currentAbort = null; + activeAborts.delete(controller); } } @@ -262,7 +272,8 @@ async function cancelRun(): Promise { permissionWaiters.clear(); for (const [, resolve] of askUserWaiters) resolve({ cancelled: true, answers: [] }); askUserWaiters.clear(); - currentAbort?.abort(); + for (const controller of activeAborts) controller.abort(); + activeAborts.clear(); await session?.interrupt().catch(() => undefined); } diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index 0bdfad3a4..87c00562c 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -4061,7 +4061,10 @@ describe("aiOrchestratorService", () => { await Promise.race([ firstReconcileEntered, new Promise((_, reject) => - setTimeout(() => reject(new Error("first reconcile did not enter within 180s")), 180_000) + setTimeout( + () => reject(new Error(`first reconcile did not enter within 280s (reconcileCalls=${reconcileCalls})`)), + 280_000, + ), ), ]); expect(reconcileCalls).toBe(1); @@ -4076,7 +4079,7 @@ describe("aiOrchestratorService", () => { releaseFirstSweep(); fixture.dispose(); } - }, 240_000); + }, 300_000); it("skips background health sweeps for runs blocked on open interventions", async () => { const fixture = await createFixture(); diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 2499cebca..8d4762e95 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1357,7 +1357,9 @@ async function callLocalProjectSyncIfBound( })) as T; return { handled: true, result }; } catch (error) { - if (!allowLocalRuntimeFallback || !isSafeLocalRuntimeFallbackError(error)) { + const canUseFallback = + allowLocalRuntimeFallback || isLocalRuntimeActionNotCallableError(error); + if (!canUseFallback || !isSafeLocalRuntimeFallbackError(error)) { throw error; } console.warn( diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index a9f4e6b60..5aaa37a31 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -2403,7 +2403,7 @@ export function AgentChatPane({ const sessionProvider = useMemo(() => { if (selectedSession && !modelSelectionDiffersFromSession) return selectedSession.provider; - return resolveChatRuntimeProvider(getModelById(modelId)); + return resolveChatRuntimeProvider(resolveModelDescriptorWithRuntimeCatalog(modelId) ?? getModelById(modelId)); }, [selectedSession, modelSelectionDiffersFromSession, modelId]); const effectiveCursorModeSnapshot = useMemo(() => { if (sessionProvider !== "cursor") return null; @@ -2733,7 +2733,7 @@ export function AgentChatPane({ const refreshAvailableModels = useCallback(async () => { ++availableModelsRefreshSeqRef.current; const selectedModelProvider = modelId.trim() - ? resolveChatRuntimeProvider(getModelById(modelId)) + ? resolveChatRuntimeProvider(resolveModelDescriptorWithRuntimeCatalog(modelId) ?? getModelById(modelId)) : null; const shouldRefreshOpenCodeInventory = sessionProvider === "opencode" diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx index a086834a7..f4acef8e2 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx @@ -435,7 +435,9 @@ export const ModelPickerContent = memo(function ModelPickerContent({ const handleCopyId = useCallback((modelId: string) => { try { - void navigator.clipboard.writeText(modelId); + void navigator.clipboard.writeText(modelId).catch(() => { + // ignore clipboard failures + }); } catch { // ignore clipboard failures } diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/useModelFavorites.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/useModelFavorites.ts index 85b1119b6..c7455e511 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/useModelFavorites.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/useModelFavorites.ts @@ -85,7 +85,8 @@ const useFavoritesStore = create((set, get) => ({ hydrateFromRemote: async () => { const api = getRpcApi(); if (!api) { - set({ hydrated: true }); + // RPC surface not yet bound — leave hydrated:false so we retry once it + // becomes available, instead of permanently locking on stale local cache. return; } try { @@ -99,8 +100,6 @@ const useFavoritesStore = create((set, get) => ({ }, })); -let hydrationStarted = false; - export function useModelFavorites(): { favorites: string[]; toggleFavorite: (modelId: string) => void; @@ -117,8 +116,7 @@ export function useModelFavorites(): { ); useEffect(() => { - if (hydrationStarted || hydrated) return; - hydrationStarted = true; + if (hydrated) return; void hydrateFromRemote(); }, [hydrateFromRemote, hydrated]); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.ts index 5758ac9ed..d38609a13 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/useModelRecents.ts @@ -86,7 +86,8 @@ const useRecentsStore = create((set, get) => ({ hydrateFromRemote: async () => { const api = getRpcApi(); if (!api) { - set({ hydrated: true }); + // RPC surface not yet bound — leave hydrated:false so we retry once it + // becomes available, instead of permanently locking on stale local cache. return; } try { @@ -100,8 +101,6 @@ const useRecentsStore = create((set, get) => ({ }, })); -let hydrationStarted = false; - export function useModelRecents(): { recents: string[]; recordUsage: (modelId: string) => void; @@ -117,8 +116,7 @@ export function useModelRecents(): { ); useEffect(() => { - if (hydrationStarted || hydrated) return; - hydrationStarted = true; + if (hydrated) return; void hydrateFromRemote(); }, [hydrateFromRemote, hydrated]); diff --git a/apps/desktop/src/shared/modelCatalog.ts b/apps/desktop/src/shared/modelCatalog.ts index 7035c5504..2dd26c9eb 100644 --- a/apps/desktop/src/shared/modelCatalog.ts +++ b/apps/desktop/src/shared/modelCatalog.ts @@ -148,7 +148,7 @@ export function providerBadgeColor(provider: string, models: ModelDescriptor[]): export function classifyProviderGroup(model: ModelDescriptor): ProviderGroupKey { if (model.family === "cursor") return "cursor"; - if (model.providerRoute === "opencode" && (model.family === "ollama" || model.family === "lmstudio")) { + if (model.family === "ollama" || model.family === "lmstudio") { return model.family; } if (model.isCliWrapped) {