From a8b0e425c94bf14d53d735a9064fa2dab0811554 Mon Sep 17 00:00:00 2001 From: Brandon Charleson Date: Mon, 9 Mar 2026 05:06:04 -0700 Subject: [PATCH 1/4] fix cursor popups and add LM Studio onboarding Prevent duplicate activation issues in Cursor, make selector popups render opaquely, and extend local onboarding so LM Studio can be configured alongside Ollama. Made-with: Cursor --- core/config/onboarding.ts | 208 ++++++++++++++--- core/config/onboarding.vitest.ts | 87 ++++++++ core/core.ts | 15 +- core/index.d.ts | 1 + extensions/vscode/package-lock.json | 9 +- extensions/vscode/package.json | 2 +- extensions/vscode/src/activation/activate.ts | 12 + extensions/vscode/src/commands.ts | 18 +- extensions/vscode/src/extension.ts | 10 +- .../AssistantAndOrgListbox/index.tsx | 25 +-- .../components/OnboardingLocalTab.tsx | 211 +++++++++++++----- .../hooks/useSubmitOnboarding.ts | 7 +- .../modelSelection/ModelSelectionListbox.tsx | 26 +-- gui/src/components/ui/Listbox.tsx | 17 +- tailwind.config.cjs | 1 + 15 files changed, 524 insertions(+), 125 deletions(-) create mode 100644 core/config/onboarding.vitest.ts create mode 100644 tailwind.config.cjs diff --git a/core/config/onboarding.ts b/core/config/onboarding.ts index dde0fefe70a..6244506da5d 100644 --- a/core/config/onboarding.ts +++ b/core/config/onboarding.ts @@ -1,12 +1,153 @@ import { ConfigYaml } from "@continuedev/config-yaml"; -export const LOCAL_ONBOARDING_PROVIDER_TITLE = "Ollama"; -export const LOCAL_ONBOARDING_FIM_MODEL = "qwen2.5-coder:1.5b-base"; -export const LOCAL_ONBOARDING_FIM_TITLE = "Qwen2.5-Coder 1.5B"; -export const LOCAL_ONBOARDING_CHAT_MODEL = "llama3.1:8b"; -export const LOCAL_ONBOARDING_CHAT_TITLE = "Llama 3.1 8B"; -export const LOCAL_ONBOARDING_EMBEDDINGS_MODEL = "nomic-embed-text:latest"; -export const LOCAL_ONBOARDING_EMBEDDINGS_TITLE = "Nomic Embed"; +export type LocalOnboardingProvider = "ollama" | "lmstudio"; + +export const DEFAULT_LOCAL_ONBOARDING_PROVIDER: LocalOnboardingProvider = + "ollama"; + +type LocalOnboardingModel = { + name: string; + provider: LocalOnboardingProvider; + model: string; + roles: ( + | "chat" + | "autocomplete" + | "embed" + | "edit" + | "apply" + | "summarize" + | "subagent" + | "rerank" + )[]; +}; + +type LocalOnboardingConfig = { + providerTitle: string; + chatTitle: string; + chatModel: string; + autocompleteTitle?: string; + autocompleteModel?: string; + embeddingsTitle?: string; + embeddingsModel?: string; +}; + +const OLLAMA_LOCAL_ONBOARDING_CONFIG: LocalOnboardingConfig = { + providerTitle: "Ollama", + chatTitle: "Llama 3.1 8B", + chatModel: "llama3.1:8b", + autocompleteTitle: "Qwen2.5-Coder 1.5B", + autocompleteModel: "qwen2.5-coder:1.5b-base", + embeddingsTitle: "Nomic Embed", + embeddingsModel: "nomic-embed-text:latest", +}; + +const LMSTUDIO_LOCAL_ONBOARDING_CONFIG: LocalOnboardingConfig = { + providerTitle: "LM Studio", + chatTitle: "LM Studio", + chatModel: "AUTODETECT", +}; + +export function getLocalOnboardingConfig( + provider: LocalOnboardingProvider = DEFAULT_LOCAL_ONBOARDING_PROVIDER, +): LocalOnboardingConfig { + return provider === "lmstudio" + ? LMSTUDIO_LOCAL_ONBOARDING_CONFIG + : OLLAMA_LOCAL_ONBOARDING_CONFIG; +} + +function dedupeModels(models?: string[]) { + if (!models?.length) { + return []; + } + + return Array.from( + new Set(models.map((model) => model.trim()).filter(Boolean)), + ); +} + +function getPreferredModel( + models: string[], + matchers: RegExp[], + fallback: string, +): string { + return ( + models.find((model) => matchers.some((matcher) => matcher.test(model))) ?? + fallback + ); +} + +function getLmStudioOnboardingModels( + models?: string[], +): LocalOnboardingModel[] { + const availableModels = dedupeModels(models); + const fallbackConfig = getLocalOnboardingConfig("lmstudio"); + const fallbackModel = availableModels[0] ?? fallbackConfig.chatModel; + const chatModel = + availableModels.find( + (model) => + [/instruct/i, /chat/i, /assistant/i].some((matcher) => + matcher.test(model), + ) && !/(coder|code|codestral)/i.test(model), + ) ?? + getPreferredModel( + availableModels, + [/instruct/i, /chat/i, /assistant/i], + fallbackModel, + ); + const autocompleteModel = getPreferredModel( + availableModels, + [/coder/i, /code/i, /codestral/i, /deepseek/i, /qwen/i], + chatModel, + ); + const embeddingsModel = getPreferredModel( + availableModels, + [/embed/i, /embedding/i, /nomic/i, /bge/i, /\be5\b/i], + "", + ); + + const localModels: LocalOnboardingModel[] = [ + { + name: chatModel, + provider: "lmstudio", + model: chatModel, + roles: + autocompleteModel === chatModel + ? ["chat", "edit", "apply", "autocomplete"] + : ["chat", "edit", "apply"], + }, + ]; + + if (autocompleteModel !== chatModel) { + localModels.push({ + name: autocompleteModel, + provider: "lmstudio", + model: autocompleteModel, + roles: ["autocomplete"], + }); + } + + if (embeddingsModel && embeddingsModel !== chatModel) { + localModels.push({ + name: embeddingsModel, + provider: "lmstudio", + model: embeddingsModel, + roles: ["embed"], + }); + } + + return localModels; +} + +export function getLocalOnboardingPrimaryModelTitle( + provider: LocalOnboardingProvider = DEFAULT_LOCAL_ONBOARDING_PROVIDER, + availableModels?: string[], +) { + if (provider === "lmstudio") { + return getLmStudioOnboardingModels(availableModels)[0]?.name ?? "LM Studio"; + } + + return getLocalOnboardingConfig(provider).chatTitle; +} const ANTHROPIC_MODEL_CONFIG = { slugs: ["anthropic/claude-3-7-sonnet", "anthropic/claude-4-sonnet"], @@ -34,30 +175,39 @@ export function setupBestConfig(config: ConfigYaml): ConfigYaml { }; } -export function setupLocalConfig(config: ConfigYaml): ConfigYaml { +export function setupLocalConfig( + config: ConfigYaml, + provider: LocalOnboardingProvider = DEFAULT_LOCAL_ONBOARDING_PROVIDER, + availableModels?: string[], +): ConfigYaml { + const onboardingConfig = getLocalOnboardingConfig(provider); + const localModels: LocalOnboardingModel[] = + provider === "lmstudio" + ? getLmStudioOnboardingModels(availableModels) + : [ + { + name: onboardingConfig.chatTitle, + provider: "ollama", + model: onboardingConfig.chatModel, + roles: ["chat", "edit", "apply"], + }, + { + name: onboardingConfig.autocompleteTitle!, + provider: "ollama", + model: onboardingConfig.autocompleteModel!, + roles: ["autocomplete"], + }, + { + name: onboardingConfig.embeddingsTitle!, + provider: "ollama", + model: onboardingConfig.embeddingsModel!, + roles: ["embed"], + }, + ]; + return { ...config, - models: [ - { - name: LOCAL_ONBOARDING_CHAT_TITLE, - provider: "ollama", - model: LOCAL_ONBOARDING_CHAT_MODEL, - roles: ["chat", "edit", "apply"], - }, - { - name: LOCAL_ONBOARDING_FIM_TITLE, - provider: "ollama", - model: LOCAL_ONBOARDING_FIM_MODEL, - roles: ["autocomplete"], - }, - { - name: LOCAL_ONBOARDING_EMBEDDINGS_TITLE, - provider: "ollama", - model: LOCAL_ONBOARDING_EMBEDDINGS_MODEL, - roles: ["embed"], - }, - ...(config.models ?? []), - ], + models: [...localModels, ...(config.models ?? [])], }; } diff --git a/core/config/onboarding.vitest.ts b/core/config/onboarding.vitest.ts new file mode 100644 index 00000000000..631a76023e6 --- /dev/null +++ b/core/config/onboarding.vitest.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import { + getLocalOnboardingPrimaryModelTitle, + setupLocalConfig, +} from "./onboarding"; + +describe("setupLocalConfig", () => { + it("adds the default Ollama onboarding models", () => { + const result = setupLocalConfig({ + name: "Local Config", + version: "0.0.1", + schema: "v1", + models: [], + }); + + expect(result.models?.slice(0, 3)).toEqual([ + { + name: "Llama 3.1 8B", + provider: "ollama", + model: "llama3.1:8b", + roles: ["chat", "edit", "apply"], + }, + { + name: "Qwen2.5-Coder 1.5B", + provider: "ollama", + model: "qwen2.5-coder:1.5b-base", + roles: ["autocomplete"], + }, + { + name: "Nomic Embed", + provider: "ollama", + model: "nomic-embed-text:latest", + roles: ["embed"], + }, + ]); + }); + + it("builds an LM Studio config from detected local models", () => { + const result = setupLocalConfig( + { + name: "Local Config", + version: "0.0.1", + schema: "v1", + models: [], + }, + "lmstudio", + [ + "text-embedding-nomic-embed-text-v1.5", + "Qwen2.5-Coder-7B-Instruct-GGUF", + "Meta-Llama-3.1-8B-Instruct-GGUF", + ], + ); + + expect(result.models).toEqual([ + { + name: "Meta-Llama-3.1-8B-Instruct-GGUF", + provider: "lmstudio", + model: "Meta-Llama-3.1-8B-Instruct-GGUF", + roles: ["chat", "edit", "apply"], + }, + { + name: "Qwen2.5-Coder-7B-Instruct-GGUF", + provider: "lmstudio", + model: "Qwen2.5-Coder-7B-Instruct-GGUF", + roles: ["autocomplete"], + }, + { + name: "text-embedding-nomic-embed-text-v1.5", + provider: "lmstudio", + model: "text-embedding-nomic-embed-text-v1.5", + roles: ["embed"], + }, + ]); + }); +}); + +describe("getLocalOnboardingPrimaryModelTitle", () => { + it("returns the chosen LM Studio chat model title", () => { + expect( + getLocalOnboardingPrimaryModelTitle("lmstudio", [ + "Qwen2.5-Coder-7B-Instruct-GGUF", + "Meta-Llama-3.1-8B-Instruct-GGUF", + ]), + ).toBe("Meta-Llama-3.1-8B-Instruct-GGUF"); + }); +}); diff --git a/core/core.ts b/core/core.ts index 3d1b2cc3c57..206103fc2a4 100644 --- a/core/core.ts +++ b/core/core.ts @@ -17,6 +17,7 @@ import { CodebaseIndexer } from "./indexing/CodebaseIndexer"; import DocsService from "./indexing/docs/DocsService"; import { countTokens } from "./llm/countTokens"; import Lemonade from "./llm/llms/Lemonade"; +import LMStudio from "./llm/llms/LMStudio"; import Ollama from "./llm/llms/Ollama"; import { EditAggregator } from "./nextEdit/context/aggregateEdits"; import { createNewPromptFileV2 } from "./promptFiles/createNewPromptFile"; @@ -1395,6 +1396,9 @@ export class Core { if (msg.data.title === "Ollama") { const models = await new Ollama({ model: "" }).listModels(); return models; + } else if (msg.data.title === "LM Studio") { + const models = await new LMStudio({ model: "" }).listModels(); + return models; } else if (msg.data.title === "Lemonade") { const models = await new Lemonade({ model: "" }).listModels(); return models; @@ -1403,7 +1407,7 @@ export class Core { } } } catch (e) { - console.debug(`Error listing Ollama models: ${e}`); + console.debug(`Error listing models for ${msg.data.title}: ${e}`); return undefined; } } @@ -1411,13 +1415,18 @@ export class Core { private async handleCompleteOnboarding( msg: Message, ) { - const { mode, provider, apiKey } = msg.data; + const { mode, provider, apiKey, localModelTitles } = msg.data; let editConfigYamlCallback: (config: ConfigYaml) => ConfigYaml; switch (mode) { case OnboardingModes.LOCAL: - editConfigYamlCallback = setupLocalConfig; + editConfigYamlCallback = (config: ConfigYaml) => + setupLocalConfig( + config, + provider === "lmstudio" ? "lmstudio" : "ollama", + localModelTitles, + ); break; case OnboardingModes.API_KEY: diff --git a/core/index.d.ts b/core/index.d.ts index 82941d189e7..0475f6da6e0 100644 --- a/core/index.d.ts +++ b/core/index.d.ts @@ -1948,6 +1948,7 @@ export interface CompleteOnboardingPayload { mode: OnboardingModes; provider?: string; apiKey?: string; + localModelTitles?: string[]; } export interface CompiledMessagesResult { diff --git a/extensions/vscode/package-lock.json b/extensions/vscode/package-lock.json index 65e0644e92b..dcb1ab866fe 100644 --- a/extensions/vscode/package-lock.json +++ b/extensions/vscode/package-lock.json @@ -1,12 +1,12 @@ { "name": "continue", - "version": "1.3.30", + "version": "1.3.33", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "continue", - "version": "1.3.30", + "version": "1.3.33", "license": "Apache-2.0", "dependencies": { "@continuedev/config-types": "file:../../packages/config-types", @@ -101,8 +101,8 @@ "dependencies": { "@anthropic-ai/sdk": "^0.62.0", "@aws-sdk/client-bedrock-runtime": "^3.931.0", - "@aws-sdk/client-sagemaker-runtime": "^3.777.0", - "@aws-sdk/credential-providers": "^3.931.0", + "@aws-sdk/client-sagemaker-runtime": "^3.894.0", + "@aws-sdk/credential-providers": "^3.974.0", "@continuedev/config-types": "^1.0.14", "@continuedev/config-yaml": "file:../packages/config-yaml", "@continuedev/fetch": "file:../packages/fetch", @@ -181,6 +181,7 @@ "@babel/preset-env": "^7.24.7", "@biomejs/biome": "1.6.4", "@google/generative-ai": "^0.11.4", + "@modelcontextprotocol/ext-apps": "^1.0.1", "@shikijs/colorized-brackets": "^3.7.0", "@shikijs/transformers": "^3.7.0", "@types/diff": "^7.0.1", diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index cfe48996f18..8f9e49e19b2 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -2,7 +2,7 @@ "name": "continue", "icon": "media/icon.png", "author": "Continue Dev, Inc", - "version": "1.3.32", + "version": "1.3.33", "repository": { "type": "git", "url": "https://github.com/continuedev/continue" diff --git a/extensions/vscode/src/activation/activate.ts b/extensions/vscode/src/activation/activate.ts index 45fc24d4902..5b7595c127a 100644 --- a/extensions/vscode/src/activation/activate.ts +++ b/extensions/vscode/src/activation/activate.ts @@ -9,7 +9,19 @@ import { GlobalContext } from "core/util/GlobalContext"; import { VsCodeContinueApi } from "./api"; import setupInlineTips from "./InlineTipManager"; +async function isDuplicateHostActivation(): Promise { + const registeredCommands = await vscode.commands.getCommands(true); + return registeredCommands.includes("continue.focusContinueInput"); +} + export async function activateExtension(context: vscode.ExtensionContext) { + if (await isDuplicateHostActivation()) { + console.warn( + "Continue commands already exist; skipping duplicate extension-host activation.", + ); + return {}; + } + const platformCheck = isUnsupportedPlatform(); const globalContext = new GlobalContext(); const hasShownUnsupportedPlatformWarning = globalContext.get( diff --git a/extensions/vscode/src/commands.ts b/extensions/vscode/src/commands.ts index c9374ea9a13..a00f12b2481 100644 --- a/extensions/vscode/src/commands.ts +++ b/extensions/vscode/src/commands.ts @@ -952,8 +952,20 @@ export function registerAllCommands( editDecorationManager, ), )) { - context.subscriptions.push( - vscode.commands.registerCommand(command, callback), - ); + try { + context.subscriptions.push( + vscode.commands.registerCommand(command, callback), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("already registered")) { + console.warn( + `Skipping duplicate Continue command registration: ${command}`, + ); + continue; + } + + throw error; + } } } diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts index 2b8114e5ed9..e0614816abb 100644 --- a/extensions/vscode/src/extension.ts +++ b/extensions/vscode/src/extension.ts @@ -37,7 +37,15 @@ export function activate(context: vscode.ExtensionContext) { ) .then((selection) => { if (selection === "View Logs") { - vscode.commands.executeCommand("continue.viewLogs"); + vscode.commands.getCommands(true).then((commands) => { + if (commands.includes("continue.viewLogs")) { + return vscode.commands.executeCommand("continue.viewLogs"); + } + + return vscode.commands.executeCommand( + "workbench.action.toggleDevTools", + ); + }); } else if (selection === "Retry") { // Reload VS Code window vscode.commands.executeCommand("workbench.action.reloadWindow"); diff --git a/gui/src/components/AssistantAndOrgListbox/index.tsx b/gui/src/components/AssistantAndOrgListbox/index.tsx index cf767c6c3fa..3f46251a3d4 100644 --- a/gui/src/components/AssistantAndOrgListbox/index.tsx +++ b/gui/src/components/AssistantAndOrgListbox/index.tsx @@ -149,12 +149,9 @@ export function AssistantAndOrgListbox({ variant={variant} /> - +
- + Configs
@@ -167,7 +164,7 @@ export function AssistantAndOrgListbox({ size="sm" className="my-0 h-5 w-5 p-0" > - +
@@ -192,7 +189,7 @@ export function AssistantAndOrgListbox({ <>
- + Organizations
@@ -205,7 +202,7 @@ export function AssistantAndOrgListbox({ size="sm" className="my-0 h-5 w-5 p-0" > - +
@@ -237,7 +234,7 @@ export function AssistantAndOrgListbox({ }} variant="ghost" size="sm" - className="text-description hover:bg-input my-0 w-full justify-start py-1.5 pl-1 text-left" + className="my-0 w-full justify-start py-1.5 pl-1 text-left text-description hover:bg-input" >
@@ -274,7 +271,7 @@ export function AssistantAndOrgListbox({ }} variant="ghost" size="sm" - className="text-description hover:bg-input my-0 w-full justify-start py-1.5 pl-1 text-left" + className="my-0 w-full justify-start py-1.5 pl-1 text-left text-description hover:bg-input" >
@@ -289,7 +286,7 @@ export function AssistantAndOrgListbox({ {/* Bottom Actions */}
-
+
{getMetaKeyLabel()} ⇧ ' to toggle config diff --git a/gui/src/components/OnboardingCard/components/OnboardingLocalTab.tsx b/gui/src/components/OnboardingCard/components/OnboardingLocalTab.tsx index 9e654a574a3..298d5324614 100644 --- a/gui/src/components/OnboardingCard/components/OnboardingLocalTab.tsx +++ b/gui/src/components/OnboardingCard/components/OnboardingLocalTab.tsx @@ -1,15 +1,15 @@ import { - LOCAL_ONBOARDING_CHAT_MODEL, - LOCAL_ONBOARDING_CHAT_TITLE, - LOCAL_ONBOARDING_EMBEDDINGS_MODEL, - LOCAL_ONBOARDING_FIM_MODEL, - LOCAL_ONBOARDING_PROVIDER_TITLE, + DEFAULT_LOCAL_ONBOARDING_PROVIDER, + getLocalOnboardingConfig, + getLocalOnboardingPrimaryModelTitle, + type LocalOnboardingProvider, } from "core/config/onboarding"; import { OnboardingModes } from "core/protocol/core"; import { useContext, useEffect, useState } from "react"; import { Button } from "../.."; import { useAuth } from "../../../context/Auth"; import { IdeMessengerContext } from "../../../context/IdeMessenger"; +import { providers } from "../../../pages/AddNewModel/configs/providers"; import { useAppDispatch } from "../../../redux/hooks"; import { setDialogMessage, setShowDialog } from "../../../redux/slices/uiSlice"; import { updateSelectedModelByRole } from "../../../redux/thunks/updateSelectedModelByRole"; @@ -31,67 +31,90 @@ export function OnboardingLocalTab({ isDialog }: OnboardingLocalTabProps) { isDialog, ); const [hasLoadedChatModel, setHasLoadedChatModel] = useState(false); - const [downloadedOllamaModels, setDownloadedOllamaModels] = useState< - string[] - >([]); + const [localProvider, setLocalProvider] = useState( + DEFAULT_LOCAL_ONBOARDING_PROVIDER, + ); + const [availableLocalModels, setAvailableLocalModels] = useState( + [], + ); const { selectedProfile } = useAuth(); - const [isOllamaConnected, setIsOllamaConnected] = useState(false); + const [isProviderConnected, setIsProviderConnected] = useState(false); + + const onboardingConfig = getLocalOnboardingConfig(localProvider); + const isOllamaProvider = localProvider === "ollama"; + const providerDownloadUrl = isOllamaProvider + ? providers.ollama?.downloadUrl + : providers.lmstudio?.downloadUrl; - const hasDownloadedChatModel = Array.isArray(downloadedOllamaModels) - ? downloadedOllamaModels.some( - (ollamaModel) => ollamaModel === LOCAL_ONBOARDING_CHAT_MODEL, + const hasDownloadedChatModel = Array.isArray(availableLocalModels) + ? availableLocalModels.some( + (ollamaModel) => ollamaModel === onboardingConfig.chatModel, ) : false; - const hasDownloadedAutocompleteModel = Array.isArray(downloadedOllamaModels) - ? downloadedOllamaModels.some( - (ollamaModel) => ollamaModel === LOCAL_ONBOARDING_FIM_MODEL, + const hasDownloadedAutocompleteModel = Array.isArray(availableLocalModels) + ? availableLocalModels.some( + (ollamaModel) => ollamaModel === onboardingConfig.autocompleteModel, ) : false; - const hasDownloadedEmbeddingsModel = Array.isArray(downloadedOllamaModels) - ? downloadedOllamaModels.some( - (ollamaModel) => ollamaModel === LOCAL_ONBOARDING_EMBEDDINGS_MODEL, + const hasDownloadedEmbeddingsModel = Array.isArray(availableLocalModels) + ? availableLocalModels.some( + (ollamaModel) => ollamaModel === onboardingConfig.embeddingsModel, ) : false; - const allDownloaded = - hasDownloadedAutocompleteModel && - hasDownloadedChatModel && - hasDownloadedEmbeddingsModel; + const allDownloaded = isOllamaProvider + ? hasDownloadedAutocompleteModel && + hasDownloadedChatModel && + hasDownloadedEmbeddingsModel + : availableLocalModels.length > 0; /** * The first time we detect that a chat model has been loaded, * we send an empty request to load it */ useEffect(() => { - if (!hasLoadedChatModel && hasDownloadedChatModel) { + if (isOllamaProvider && !hasLoadedChatModel && hasDownloadedChatModel) { ideMessenger.post("llm/complete", { completionOptions: {}, prompt: "", - title: LOCAL_ONBOARDING_PROVIDER_TITLE, + title: onboardingConfig.providerTitle, }); setHasLoadedChatModel(true); } - }, [downloadedOllamaModels, isOllamaConnected]); + }, [ + availableLocalModels, + hasDownloadedChatModel, + hasLoadedChatModel, + ideMessenger, + isOllamaProvider, + onboardingConfig.providerTitle, + ]); + + useEffect(() => { + setHasLoadedChatModel(false); + }, [localProvider]); useEffect(() => { const fetchDownloadedModels = async () => { try { const result = await ideMessenger.request("llm/listModels", { - title: LOCAL_ONBOARDING_PROVIDER_TITLE, + title: onboardingConfig.providerTitle, }); if (result.status === "success") { - setDownloadedOllamaModels(result.content ?? []); - setIsOllamaConnected(!!result.content); + const models = result.content ?? []; + setAvailableLocalModels(models); + setIsProviderConnected(Array.isArray(result.content)); } else { throw new Error("Failed to fetch models"); } } catch (error) { console.error("Error fetching models:", error); - setIsOllamaConnected(false); + setAvailableLocalModels([]); + setIsProviderConnected(false); } }; @@ -103,10 +126,10 @@ export function OnboardingLocalTab({ isDialog }: OnboardingLocalTabProps) { void fetchDownloadedModels(); return () => clearInterval(intervalId); - }, []); + }, [ideMessenger, onboardingConfig.providerTitle]); const onClickSubmitOnboarding = () => { - submitOnboarding(); + submitOnboarding(localProvider, undefined, availableLocalModels); if (isDialog) { dispatch(setDialogMessage(undefined)); @@ -117,7 +140,10 @@ export function OnboardingLocalTab({ isDialog }: OnboardingLocalTabProps) { updateSelectedModelByRole({ selectedProfile, role: "chat", - modelTitle: LOCAL_ONBOARDING_CHAT_TITLE, + modelTitle: getLocalOnboardingPrimaryModelTitle( + localProvider, + availableLocalModels, + ), }), ); }; @@ -139,30 +165,113 @@ export function OnboardingLocalTab({ isDialog }: OnboardingLocalTabProps) {
+
+ {(["ollama", "lmstudio"] as LocalOnboardingProvider[]).map( + (providerKey) => { + const isSelected = providerKey === localProvider; + const providerTitle = + getLocalOnboardingConfig(providerKey).providerTitle; + + return ( + + ); + }, + )} +
+
-

- Install Ollama +

+ {isOllamaProvider ? "Install Ollama" : "Install LM Studio"}

- + {isOllamaProvider ? ( + + ) : ( +
+ +
+ {isProviderConnected ? ( + + Connected to LM Studio at{" "} + http://localhost:1234/v1 + + ) : ( + + Start the LM Studio local inference server at{" "} + http://localhost:1234/v1 + + )} +
+
+ )}
- + {isOllamaProvider ? ( + <> + - + - + + + ) : ( +
+

+ Available LM Studio models +

+
+ {availableLocalModels.length > 0 ? ( +
+ {availableLocalModels.slice(0, 4).map((modelName) => ( + + {modelName} + + ))} + {availableLocalModels.length > 4 && ( + + +{availableLocalModels.length - 4} more + + )} +
+ ) : ( + + No models detected yet. Load a model in LM Studio and start + the local server. + + )} +
+
+ )}
@@ -189,7 +189,7 @@ export function AssistantAndOrgListbox({ <>
- + Organizations
@@ -202,7 +202,7 @@ export function AssistantAndOrgListbox({ size="sm" className="my-0 h-5 w-5 p-0" > - +
@@ -234,7 +234,7 @@ export function AssistantAndOrgListbox({ }} variant="ghost" size="sm" - className="my-0 w-full justify-start py-1.5 pl-1 text-left text-description hover:bg-input" + className="text-description hover:bg-input my-0 w-full justify-start py-1.5 pl-1 text-left" >
@@ -271,7 +271,7 @@ export function AssistantAndOrgListbox({ }} variant="ghost" size="sm" - className="my-0 w-full justify-start py-1.5 pl-1 text-left text-description hover:bg-input" + className="text-description hover:bg-input my-0 w-full justify-start py-1.5 pl-1 text-left" >
@@ -286,7 +286,7 @@ export function AssistantAndOrgListbox({ {/* Bottom Actions */}
-
+
{getMetaKeyLabel()} ⇧ ' to toggle config diff --git a/gui/src/components/OnboardingCard/components/OnboardingLocalTab.tsx b/gui/src/components/OnboardingCard/components/OnboardingLocalTab.tsx index 298d5324614..60d56f0933a 100644 --- a/gui/src/components/OnboardingCard/components/OnboardingLocalTab.tsx +++ b/gui/src/components/OnboardingCard/components/OnboardingLocalTab.tsx @@ -177,7 +177,7 @@ export function OnboardingLocalTab({ isDialog }: OnboardingLocalTabProps) { key={providerKey} type="button" onClick={() => setLocalProvider(providerKey)} - className={`flex-1 cursor-pointer rounded border px-3 py-2 text-sm transition-colors ${isSelected ? "border-border-focus bg-input text-foreground" : "border-border bg-transparent text-description hover:bg-input"}`} + className={`flex-1 cursor-pointer rounded border px-3 py-2 text-sm transition-colors ${isSelected ? "border-border-focus bg-input text-foreground" : "border-border text-description hover:bg-input bg-transparent"}`} > {providerTitle} @@ -187,7 +187,7 @@ export function OnboardingLocalTab({ isDialog }: OnboardingLocalTabProps) {
-

+

{isOllamaProvider ? "Install Ollama" : "Install LM Studio"}

{isOllamaProvider ? ( @@ -207,7 +207,7 @@ export function OnboardingLocalTab({ isDialog }: OnboardingLocalTabProps) { {providerDownloadUrl} -
+
{isProviderConnected ? ( Connected to LM Studio at{" "} @@ -246,10 +246,10 @@ export function OnboardingLocalTab({ isDialog }: OnboardingLocalTabProps) { ) : (
-

+

Available LM Studio models

-
+
{availableLocalModels.length > 0 ? (
{availableLocalModels.slice(0, 4).map((modelName) => ( @@ -258,13 +258,13 @@ export function OnboardingLocalTab({ isDialog }: OnboardingLocalTabProps) { ))} {availableLocalModels.length > 4 && ( - + +{availableLocalModels.length - 4} more )}
) : ( - + No models detected yet. Load a model in LM Studio and start the local server. @@ -283,7 +283,7 @@ export function OnboardingLocalTab({ isDialog }: OnboardingLocalTabProps) {
Skip and configure manually diff --git a/gui/src/components/modelSelection/ModelSelectionListbox.tsx b/gui/src/components/modelSelection/ModelSelectionListbox.tsx index d9ac55c76f9..9c929462fa6 100644 --- a/gui/src/components/modelSelection/ModelSelectionListbox.tsx +++ b/gui/src/components/modelSelection/ModelSelectionListbox.tsx @@ -101,7 +101,7 @@ function ModelSelectionListbox({ }} >
- + {window.vscMediaUrl && selectedProvider.icon && ( @@ -125,17 +125,17 @@ function ModelSelectionListbox({ leaveFrom="opacity-100" leaveTo="opacity-0" > - + {/* Search Box */} -
-
- +
+
+ setSearchQuery(e.target.value)} - className="w-full border-0 bg-editor px-2 py-1.5 text-foreground placeholder-description-muted outline-none" + className="bg-editor text-foreground placeholder-description-muted w-full border-0 px-2 py-1.5 outline-none" onClick={(e) => e.stopPropagation()} />
@@ -144,21 +144,21 @@ function ModelSelectionListbox({ {/* Results */}
{!hasResults ? ( -
+
No models found matching "{searchQuery}"
) : ( <> {filteredTopOptions.length > 0 && (
-
+
Popular
{filteredTopOptions.map((option, index) => ( - ` ${selected ? "bg-list-active" : "bg-editor"} relative flex cursor-pointer select-none items-center justify-between gap-2 p-1.5 px-3 py-2 pr-4 hover:bg-list-active hover:text-list-active-foreground` + ` ${selected ? "bg-list-active" : "bg-editor"} hover:bg-list-active hover:text-list-active-foreground relative flex cursor-pointer select-none items-center justify-between gap-2 p-1.5 px-3 py-2 pr-4` } value={option} > @@ -192,18 +192,18 @@ function ModelSelectionListbox({ )} {filteredTopOptions.length > 0 && filteredOtherOptions.length > 0 && ( -
+
)} {filteredOtherOptions.length > 0 && (
-
+
Additional providers
{filteredOtherOptions.map((option, index) => ( - ` ${selected ? "bg-list-active" : "bg-editor"} relative flex cursor-pointer select-none items-center justify-between gap-2 p-1.5 px-3 py-2 pr-4 hover:bg-list-active hover:text-list-active-foreground` + ` ${selected ? "bg-list-active" : "bg-editor"} hover:bg-list-active hover:text-list-active-foreground relative flex cursor-pointer select-none items-center justify-between gap-2 p-1.5 px-3 py-2 pr-4` } value={option} > diff --git a/gui/src/components/ui/Listbox.tsx b/gui/src/components/ui/Listbox.tsx index 2d45d87c71a..06989a8a96d 100644 --- a/gui/src/components/ui/Listbox.tsx +++ b/gui/src/components/ui/Listbox.tsx @@ -25,7 +25,7 @@ const ListboxButton = React.forwardRef( ref={ref} {...props} className={cn( - "m-0 flex flex-1 cursor-pointer flex-row items-center gap-1 border border-solid border-border bg-vsc-input-background px-1 py-0.5 text-left text-vsc-foreground transition-colors duration-200", + "border-border bg-vsc-input-background text-vsc-foreground m-0 flex flex-1 cursor-pointer flex-row items-center gap-1 border border-solid px-1 py-0.5 text-left transition-colors duration-200", props.className, )} style={{ @@ -50,7 +50,7 @@ const ListboxOptions = React.forwardRef( anchor={"bottom start"} {...props} className={cn( - "flex w-max min-w-[160px] max-w-[400px] flex-col overflow-auto bg-vsc-input-background px-0 shadow-md", + "bg-vsc-input-background flex w-max min-w-[160px] max-w-[400px] flex-col overflow-auto px-0 shadow-md", props.className, )} style={{ @@ -79,10 +79,10 @@ const ListboxOption = React.forwardRef( ref={ref} {...props} className={cn( - "flex select-none flex-row items-center justify-between px-2 py-1 text-foreground", + "text-foreground flex select-none flex-row items-center justify-between px-2 py-1", props.disabled ? "opacity-50" - : "background-transparent cursor-pointer opacity-100 hover:bg-list-active hover:text-list-active-foreground", + : "background-transparent hover:bg-list-active hover:text-list-active-foreground cursor-pointer opacity-100", props.className, )} style={{ diff --git a/tailwind.config.cjs b/tailwind.config.cjs index e787482a775..2c7d0dbcfaa 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -1 +1,5 @@ -module.exports = require("./gui/tailwind.config.cjs"); +module.exports = { + content: [], + theme: {}, + plugins: [], +};