Преглед изворни кода

Merge pull request #1122 from System233/patch-model-picker

fix: ModelID cannot be saved and refactor ModelPicker
Chris Estreich пре 10 месеци
родитељ
комит
3860abf855

+ 6 - 0
webview-ui/src/__mocks__/lucide-react.ts

@@ -0,0 +1,6 @@
+import React from "react"
+
+export const Check = () => React.createElement("div")
+export const ChevronsUpDown = () => React.createElement("div")
+export const Loader = () => React.createElement("div")
+export const X = () => React.createElement("div")

+ 3 - 0
webview-ui/src/__mocks__/vscrui.ts

@@ -8,6 +8,9 @@ export const Dropdown = ({ children, value, onChange }: any) =>
 
 export const Pane = ({ children }: any) => React.createElement("div", { "data-testid": "mock-pane" }, children)
 
+export const Button = ({ children, ...props }: any) =>
+	React.createElement("div", { "data-testid": "mock-button", ...props }, children)
+
 export type DropdownOption = {
 	label: string
 	value: string

+ 16 - 0
webview-ui/src/components/settings/ApiErrorMessage.tsx

@@ -0,0 +1,16 @@
+import React from "react"
+
+interface ApiErrorMessageProps {
+	errorMessage: string | undefined
+	children?: React.ReactNode
+}
+const ApiErrorMessage = ({ errorMessage, children }: ApiErrorMessageProps) => {
+	return (
+		<div className="text-vscode-errorForeground text-sm">
+			<span style={{ fontSize: "2em" }} className={`codicon codicon-close align-middle mr-1`} />
+			{errorMessage}
+			{children}
+		</div>
+	)
+}
+export default ApiErrorMessage

+ 193 - 66
webview-ui/src/components/settings/ApiOptions.tsx

@@ -1,4 +1,4 @@
-import { memo, useCallback, useMemo, useState } from "react"
+import React, { memo, useCallback, useEffect, useMemo, useState } from "react"
 import { useDebounce, useEvent } from "react-use"
 import { Checkbox, Dropdown, Pane, type DropdownOption } from "vscrui"
 import { VSCodeLink, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
@@ -39,35 +39,47 @@ import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
 
 import { vscode } from "../../utils/vscode"
 import VSCodeButtonLink from "../common/VSCodeButtonLink"
-import { OpenRouterModelPicker } from "./OpenRouterModelPicker"
-import OpenAiModelPicker from "./OpenAiModelPicker"
-import { GlamaModelPicker } from "./GlamaModelPicker"
-import { UnboundModelPicker } from "./UnboundModelPicker"
 import { ModelInfoView } from "./ModelInfoView"
 import { DROPDOWN_Z_INDEX } from "./styles"
-import { RequestyModelPicker } from "./RequestyModelPicker"
+import { ModelPicker } from "./ModelPicker"
 import { TemperatureControl } from "./TemperatureControl"
+import { validateApiConfiguration, validateModelId } from "@/utils/validate"
+import ApiErrorMessage from "./ApiErrorMessage"
 
 interface ApiOptionsProps {
 	uriScheme: string | undefined
-	apiConfiguration: ApiConfiguration | undefined
+	apiConfiguration: ApiConfiguration
 	setApiConfigurationField: <K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => void
-	apiErrorMessage?: string
-	modelIdErrorMessage?: string
 	fromWelcomeView?: boolean
+	errorMessage: string | undefined
+	setErrorMessage: React.Dispatch<React.SetStateAction<string | undefined>>
 }
 
 const ApiOptions = ({
 	uriScheme,
 	apiConfiguration,
 	setApiConfigurationField,
-	apiErrorMessage,
-	modelIdErrorMessage,
 	fromWelcomeView,
+	errorMessage,
+	setErrorMessage,
 }: ApiOptionsProps) => {
 	const [ollamaModels, setOllamaModels] = useState<string[]>([])
 	const [lmStudioModels, setLmStudioModels] = useState<string[]>([])
 	const [vsCodeLmModels, setVsCodeLmModels] = useState<vscodemodels.LanguageModelChatSelector[]>([])
+	const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
+		[openRouterDefaultModelId]: openRouterDefaultModelInfo,
+	})
+	const [glamaModels, setGlamaModels] = useState<Record<string, ModelInfo>>({
+		[glamaDefaultModelId]: glamaDefaultModelInfo,
+	})
+	const [unboundModels, setUnboundModels] = useState<Record<string, ModelInfo>>({
+		[unboundDefaultModelId]: unboundDefaultModelInfo,
+	})
+	const [requestyModels, setRequestyModels] = useState<Record<string, ModelInfo>>({
+		[requestyDefaultModelId]: requestyDefaultModelInfo,
+	})
+	const [openAiModels, setOpenAiModels] = useState<Record<string, ModelInfo> | null>(null)
+
 	const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl)
 	const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion)
 	const [openRouterBaseUrlSelected, setOpenRouterBaseUrlSelected] = useState(!!apiConfiguration?.openRouterBaseUrl)
@@ -105,24 +117,100 @@ const ApiOptions = ({
 				vscode.postMessage({ type: "requestLmStudioModels", text: apiConfiguration?.lmStudioBaseUrl })
 			} else if (selectedProvider === "vscode-lm") {
 				vscode.postMessage({ type: "requestVsCodeLmModels" })
+			} else if (selectedProvider === "openai") {
+				vscode.postMessage({
+					type: "refreshOpenAiModels",
+					values: {
+						baseUrl: apiConfiguration?.openAiBaseUrl,
+						apiKey: apiConfiguration?.openAiApiKey,
+					},
+				})
+			} else if (selectedProvider === "openrouter") {
+				vscode.postMessage({ type: "refreshOpenRouterModels", values: {} })
+			} else if (selectedProvider === "glama") {
+				vscode.postMessage({ type: "refreshGlamaModels", values: {} })
+			} else if (selectedProvider === "requesty") {
+				vscode.postMessage({
+					type: "refreshRequestyModels",
+					values: {
+						apiKey: apiConfiguration?.requestyApiKey,
+					},
+				})
 			}
 		},
 		250,
-		[selectedProvider, apiConfiguration?.ollamaBaseUrl, apiConfiguration?.lmStudioBaseUrl],
+		[
+			selectedProvider,
+			apiConfiguration?.ollamaBaseUrl,
+			apiConfiguration?.lmStudioBaseUrl,
+			apiConfiguration?.openAiBaseUrl,
+			apiConfiguration?.openAiApiKey,
+			apiConfiguration?.requestyApiKey,
+		],
 	)
 
+	useEffect(() => {
+		const apiValidationResult =
+			validateApiConfiguration(apiConfiguration) ||
+			validateModelId(apiConfiguration, glamaModels, openRouterModels, unboundModels)
+		setErrorMessage(apiValidationResult)
+	}, [apiConfiguration, glamaModels, openRouterModels, setErrorMessage, unboundModels])
+
 	const handleMessage = useCallback((event: MessageEvent) => {
 		const message: ExtensionMessage = event.data
-
-		if (message.type === "ollamaModels" && Array.isArray(message.ollamaModels)) {
-			const newModels = message.ollamaModels
-			setOllamaModels(newModels)
-		} else if (message.type === "lmStudioModels" && Array.isArray(message.lmStudioModels)) {
-			const newModels = message.lmStudioModels
-			setLmStudioModels(newModels)
-		} else if (message.type === "vsCodeLmModels" && Array.isArray(message.vsCodeLmModels)) {
-			const newModels = message.vsCodeLmModels
-			setVsCodeLmModels(newModels)
+		switch (message.type) {
+			case "ollamaModels":
+				{
+					const newModels = message.ollamaModels ?? []
+					setOllamaModels(newModels)
+				}
+				break
+			case "lmStudioModels":
+				{
+					const newModels = message.lmStudioModels ?? []
+					setLmStudioModels(newModels)
+				}
+				break
+			case "vsCodeLmModels":
+				{
+					const newModels = message.vsCodeLmModels ?? []
+					setVsCodeLmModels(newModels)
+				}
+				break
+			case "glamaModels": {
+				const updatedModels = message.glamaModels ?? {}
+				setGlamaModels({
+					[glamaDefaultModelId]: glamaDefaultModelInfo, // in case the extension sent a model list without the default model
+					...updatedModels,
+				})
+				break
+			}
+			case "openRouterModels": {
+				const updatedModels = message.openRouterModels ?? {}
+				setOpenRouterModels({
+					[openRouterDefaultModelId]: openRouterDefaultModelInfo, // in case the extension sent a model list without the default model
+					...updatedModels,
+				})
+				break
+			}
+			case "openAiModels": {
+				const updatedModels = message.openAiModels ?? []
+				setOpenAiModels(Object.fromEntries(updatedModels.map((item) => [item, openAiModelInfoSaneDefaults])))
+				break
+			}
+			case "unboundModels": {
+				const updatedModels = message.unboundModels ?? {}
+				setUnboundModels(updatedModels)
+				break
+			}
+			case "requestyModels": {
+				const updatedModels = message.requestyModels ?? {}
+				setRequestyModels({
+					[requestyDefaultModelId]: requestyDefaultModelInfo, // in case the extension sent a model list without the default model
+					...updatedModels,
+				})
+				break
+			}
 		}
 	}, [])
 
@@ -548,6 +636,7 @@ const ApiOptions = ({
 							]}
 						/>
 					</div>
+					{errorMessage && <ApiErrorMessage errorMessage={errorMessage} />}
 					<p
 						style={{
 							fontSize: "12px",
@@ -617,7 +706,18 @@ const ApiOptions = ({
 						placeholder="Enter API Key...">
 						<span style={{ fontWeight: 500 }}>API Key</span>
 					</VSCodeTextField>
-					<OpenAiModelPicker />
+					<ModelPicker
+						apiConfiguration={apiConfiguration}
+						modelIdKey="openAiModelId"
+						modelInfoKey="openAiCustomModelInfo"
+						serviceName="OpenAI"
+						serviceUrl="https://platform.openai.com"
+						recommendedModel="gpt-4-turbo-preview"
+						models={openAiModels}
+						setApiConfigurationField={setApiConfigurationField}
+						defaultModelInfo={openAiModelInfoSaneDefaults}
+						errorMessage={errorMessage}
+					/>
 					<div style={{ display: "flex", alignItems: "center" }}>
 						<Checkbox
 							checked={apiConfiguration?.openAiStreamingEnabled ?? true}
@@ -705,7 +805,7 @@ const ApiOptions = ({
 												})(),
 											}}
 											title="Maximum number of tokens the model can generate in a single response"
-											onChange={handleInputChange("openAiCustomModelInfo", (e) => {
+											onInput={handleInputChange("openAiCustomModelInfo", (e) => {
 												const value = parseInt((e.target as HTMLInputElement).value)
 												return {
 													...(apiConfiguration?.openAiCustomModelInfo ||
@@ -752,7 +852,7 @@ const ApiOptions = ({
 												})(),
 											}}
 											title="Total number of tokens (input + output) the model can process in a single request"
-											onChange={handleInputChange("openAiCustomModelInfo", (e) => {
+											onInput={handleInputChange("openAiCustomModelInfo", (e) => {
 												const value = (e.target as HTMLInputElement).value
 												const parsed = parseInt(value)
 												return {
@@ -898,7 +998,7 @@ const ApiOptions = ({
 														: "var(--vscode-errorForeground)"
 												})(),
 											}}
-											onChange={handleInputChange("openAiCustomModelInfo", (e) => {
+											onInput={handleInputChange("openAiCustomModelInfo", (e) => {
 												const value = (e.target as HTMLInputElement).value
 												const parsed = parseFloat(value)
 												return {
@@ -943,7 +1043,7 @@ const ApiOptions = ({
 														: "var(--vscode-errorForeground)"
 												})(),
 											}}
-											onChange={handleInputChange("openAiCustomModelInfo", (e) => {
+											onInput={handleInputChange("openAiCustomModelInfo", (e) => {
 												const value = (e.target as HTMLInputElement).value
 												const parsed = parseFloat(value)
 												return {
@@ -980,18 +1080,6 @@ const ApiOptions = ({
 					/>
 
 					{/* end Model Info Configuration */}
-
-					<p
-						style={{
-							fontSize: "12px",
-							marginTop: 3,
-							color: "var(--vscode-descriptionForeground)",
-						}}>
-						<span style={{ color: "var(--vscode-errorForeground)" }}>
-							(<span style={{ fontWeight: 500 }}>Note:</span> Roo Code uses complex prompts and works best
-							with Claude models. Less capable models may not work as expected.)
-						</span>
-					</p>
 				</div>
 			)}
 
@@ -1012,6 +1100,8 @@ const ApiOptions = ({
 						placeholder={"e.g. meta-llama-3.1-8b-instruct"}>
 						<span style={{ fontWeight: 500 }}>Model ID</span>
 					</VSCodeTextField>
+					{errorMessage && <ApiErrorMessage errorMessage={errorMessage} />}
+
 					{lmStudioModels.length > 0 && (
 						<VSCodeRadioGroup
 							value={
@@ -1156,6 +1246,12 @@ const ApiOptions = ({
 						placeholder={"e.g. llama3.1"}>
 						<span style={{ fontWeight: 500 }}>Model ID</span>
 					</VSCodeTextField>
+					{errorMessage && (
+						<div className="text-vscode-errorForeground text-sm">
+							<span style={{ fontSize: "2em" }} className={`codicon codicon-close align-middle mr-1`} />
+							{errorMessage}
+						</div>
+					)}
 					{ollamaModels.length > 0 && (
 						<VSCodeRadioGroup
 							value={
@@ -1221,25 +1317,68 @@ const ApiOptions = ({
 						}}>
 						This key is stored locally and only used to make API requests from this extension.
 					</p>
-					<UnboundModelPicker />
+					<ModelPicker
+						apiConfiguration={apiConfiguration}
+						defaultModelId={unboundDefaultModelId}
+						defaultModelInfo={unboundDefaultModelInfo}
+						models={unboundModels}
+						modelInfoKey="unboundModelInfo"
+						modelIdKey="unboundModelId"
+						serviceName="Unbound"
+						serviceUrl="https://api.getunbound.ai/models"
+						recommendedModel={unboundDefaultModelId}
+						setApiConfigurationField={setApiConfigurationField}
+						errorMessage={errorMessage}
+					/>
 				</div>
 			)}
 
-			{apiErrorMessage && (
-				<p
-					style={{
-						margin: "-10px 0 4px 0",
-						fontSize: 12,
-						color: "var(--vscode-errorForeground)",
-					}}>
-					<span style={{ fontSize: "2em" }} className={`codicon codicon-close align-middle mr-1`} />
-					{apiErrorMessage}
-				</p>
+			{selectedProvider === "glama" && (
+				<ModelPicker
+					apiConfiguration={apiConfiguration ?? {}}
+					defaultModelId={glamaDefaultModelId}
+					defaultModelInfo={glamaDefaultModelInfo}
+					models={glamaModels}
+					modelInfoKey="glamaModelInfo"
+					modelIdKey="glamaModelId"
+					serviceName="Glama"
+					serviceUrl="https://glama.ai/models"
+					recommendedModel="anthropic/claude-3-7-sonnet"
+					setApiConfigurationField={setApiConfigurationField}
+					errorMessage={errorMessage}
+				/>
 			)}
 
-			{selectedProvider === "glama" && <GlamaModelPicker />}
-			{selectedProvider === "openrouter" && <OpenRouterModelPicker />}
-			{selectedProvider === "requesty" && <RequestyModelPicker />}
+			{selectedProvider === "openrouter" && (
+				<ModelPicker
+					apiConfiguration={apiConfiguration}
+					setApiConfigurationField={setApiConfigurationField}
+					defaultModelId={openRouterDefaultModelId}
+					defaultModelInfo={openRouterDefaultModelInfo}
+					models={openRouterModels}
+					modelIdKey="openRouterModelId"
+					modelInfoKey="openRouterModelInfo"
+					serviceName="OpenRouter"
+					serviceUrl="https://openrouter.ai/models"
+					recommendedModel="anthropic/claude-3.7-sonnet"
+					errorMessage={errorMessage}
+				/>
+			)}
+			{selectedProvider === "requesty" && (
+				<ModelPicker
+					apiConfiguration={apiConfiguration}
+					setApiConfigurationField={setApiConfigurationField}
+					defaultModelId={requestyDefaultModelId}
+					defaultModelInfo={requestyDefaultModelInfo}
+					models={requestyModels}
+					modelIdKey="requestyModelId"
+					modelInfoKey="requestyModelInfo"
+					serviceName="Requesty"
+					serviceUrl="https://requesty.ai"
+					recommendedModel="anthropic/claude-3-7-sonnet-latest"
+					errorMessage={errorMessage}
+				/>
+			)}
 
 			{selectedProvider !== "glama" &&
 				selectedProvider !== "openrouter" &&
@@ -1261,7 +1400,7 @@ const ApiOptions = ({
 							{selectedProvider === "deepseek" && createDropdown(deepSeekModels)}
 							{selectedProvider === "mistral" && createDropdown(mistralModels)}
 						</div>
-
+						{errorMessage && <ApiErrorMessage errorMessage={errorMessage} />}
 						<ModelInfoView
 							selectedModelId={selectedModelId}
 							modelInfo={selectedModelInfo}
@@ -1299,18 +1438,6 @@ const ApiOptions = ({
 					/>
 				</div>
 			)}
-
-			{modelIdErrorMessage && (
-				<p
-					style={{
-						margin: "-10px 0 4px 0",
-						fontSize: 12,
-						color: "var(--vscode-errorForeground)",
-					}}>
-					<span style={{ fontSize: "2em" }} className={`codicon codicon-close align-middle mr-1`} />
-					{modelIdErrorMessage}
-				</p>
-			)}
 		</div>
 	)
 }

+ 0 - 15
webview-ui/src/components/settings/GlamaModelPicker.tsx

@@ -1,15 +0,0 @@
-import { ModelPicker } from "./ModelPicker"
-import { glamaDefaultModelId } from "../../../../src/shared/api"
-
-export const GlamaModelPicker = () => (
-	<ModelPicker
-		defaultModelId={glamaDefaultModelId}
-		modelsKey="glamaModels"
-		configKey="glamaModelId"
-		infoKey="glamaModelInfo"
-		refreshMessageType="refreshGlamaModels"
-		serviceName="Glama"
-		serviceUrl="https://glama.ai/models"
-		recommendedModel="anthropic/claude-3-7-sonnet"
-	/>
-)

+ 86 - 178
webview-ui/src/components/settings/ModelPicker.tsx

@@ -1,192 +1,122 @@
 import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
-import debounce from "debounce"
-import { useMemo, useState, useCallback, useEffect, useRef } from "react"
-import { useMount } from "react-use"
-import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"
+import { useMemo, useState, useCallback, useEffect } from "react"
 
-import { cn } from "@/lib/utils"
-import {
-	Button,
-	Command,
-	CommandEmpty,
-	CommandGroup,
-	CommandInput,
-	CommandItem,
-	CommandList,
-	Popover,
-	PopoverContent,
-	PopoverTrigger,
-} from "@/components/ui"
-
-import { useExtensionState } from "../../context/ExtensionStateContext"
-import { vscode } from "../../utils/vscode"
 import { normalizeApiConfiguration } from "./ApiOptions"
 import { ModelInfoView } from "./ModelInfoView"
-
-type ModelProvider = "glama" | "openRouter" | "unbound" | "requesty" | "openAi"
-
-type ModelKeys<T extends ModelProvider> = `${T}Models`
-type ConfigKeys<T extends ModelProvider> = `${T}ModelId`
-type InfoKeys<T extends ModelProvider> = `${T}ModelInfo`
-type RefreshMessageType<T extends ModelProvider> = `refresh${Capitalize<T>}Models`
-
-interface ModelPickerProps<T extends ModelProvider = ModelProvider> {
-	defaultModelId: string
-	modelsKey: ModelKeys<T>
-	configKey: ConfigKeys<T>
-	infoKey: InfoKeys<T>
-	refreshMessageType: RefreshMessageType<T>
-	refreshValues?: Record<string, any>
+import { ApiConfiguration, ModelInfo } from "../../../../src/shared/api"
+import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem } from "../ui/combobox"
+import ApiErrorMessage from "./ApiErrorMessage"
+
+type ExtractType<T> = NonNullable<
+	{ [K in keyof ApiConfiguration]: Required<ApiConfiguration>[K] extends T ? K : never }[keyof ApiConfiguration]
+>
+
+type ModelIdKeys = NonNullable<
+	{ [K in keyof ApiConfiguration]: K extends `${string}ModelId` ? K : never }[keyof ApiConfiguration]
+>
+declare module "react" {
+	interface CSSProperties {
+		// Allow CSS variables
+		[key: `--${string}`]: string | number
+	}
+}
+interface ModelPickerProps {
+	defaultModelId?: string
+	models: Record<string, ModelInfo> | null
+	modelIdKey: ModelIdKeys
+	modelInfoKey: ExtractType<ModelInfo>
 	serviceName: string
 	serviceUrl: string
 	recommendedModel: string
-	allowCustomModel?: boolean
+	apiConfiguration: ApiConfiguration
+	setApiConfigurationField: <K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => void
+	defaultModelInfo?: ModelInfo
+	errorMessage?: string
 }
 
 export const ModelPicker = ({
 	defaultModelId,
-	modelsKey,
-	configKey,
-	infoKey,
-	refreshMessageType,
-	refreshValues,
+	models,
+	modelIdKey,
+	modelInfoKey,
 	serviceName,
 	serviceUrl,
 	recommendedModel,
-	allowCustomModel = false,
+	apiConfiguration,
+	setApiConfigurationField,
+	defaultModelInfo,
+	errorMessage,
 }: ModelPickerProps) => {
-	const [customModelId, setCustomModelId] = useState("")
-	const [isCustomModel, setIsCustomModel] = useState(false)
-	const [open, setOpen] = useState(false)
-	const [value, setValue] = useState(defaultModelId)
 	const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
-	const prevRefreshValuesRef = useRef<Record<string, any> | undefined>()
 
-	const { apiConfiguration, [modelsKey]: models, onUpdateApiConfig, setApiConfiguration } = useExtensionState()
-
-	const modelIds = useMemo(
-		() => (Array.isArray(models) ? models : Object.keys(models)).sort((a, b) => a.localeCompare(b)),
-		[models],
-	)
+	const modelIds = useMemo(() => Object.keys(models ?? {}).sort((a, b) => a.localeCompare(b)), [models])
 
 	const { selectedModelId, selectedModelInfo } = useMemo(
 		() => normalizeApiConfiguration(apiConfiguration),
 		[apiConfiguration],
 	)
-
-	const onSelectCustomModel = useCallback(
-		(modelId: string) => {
-			setCustomModelId(modelId)
-			const modelInfo = { id: modelId }
-			const apiConfig = { ...apiConfiguration, [configKey]: modelId, [infoKey]: modelInfo }
-			setApiConfiguration(apiConfig)
-			onUpdateApiConfig(apiConfig)
-			setValue(modelId)
-			setOpen(false)
-			setIsCustomModel(false)
-		},
-		[apiConfiguration, configKey, infoKey, onUpdateApiConfig, setApiConfiguration],
-	)
-
 	const onSelect = useCallback(
 		(modelId: string) => {
-			const modelInfo = Array.isArray(models)
-				? { id: modelId } // For OpenAI models which are just strings
-				: models[modelId] // For other models that have full info objects
-			const apiConfig = { ...apiConfiguration, [configKey]: modelId, [infoKey]: modelInfo }
-			setApiConfiguration(apiConfig)
-			onUpdateApiConfig(apiConfig)
-			setValue(modelId)
-			setOpen(false)
+			const modelInfo = models?.[modelId]
+			setApiConfigurationField(modelIdKey, modelId)
+			setApiConfigurationField(modelInfoKey, modelInfo ?? defaultModelInfo)
 		},
-		[apiConfiguration, configKey, infoKey, models, onUpdateApiConfig, setApiConfiguration],
+		[modelIdKey, modelInfoKey, models, setApiConfigurationField, defaultModelInfo],
 	)
-
-	const debouncedRefreshModels = useMemo(() => {
-		return debounce(() => {
-			const message = refreshValues
-				? { type: refreshMessageType, values: refreshValues }
-				: { type: refreshMessageType }
-			vscode.postMessage(message)
-		}, 100)
-	}, [refreshMessageType, refreshValues])
-
-	useMount(() => {
-		debouncedRefreshModels()
-		return () => debouncedRefreshModels.clear()
-	})
-
 	useEffect(() => {
-		if (!refreshValues) {
-			prevRefreshValuesRef.current = undefined
-			return
-		}
-
-		// Check if all values in refreshValues are truthy
-		if (Object.values(refreshValues).some((value) => !value)) {
-			prevRefreshValuesRef.current = undefined
-			return
+		if (apiConfiguration[modelIdKey] == null && defaultModelId) {
+			onSelect(defaultModelId)
 		}
-
-		// Compare with previous values
-		const prevValues = prevRefreshValuesRef.current
-		if (prevValues && JSON.stringify(prevValues) === JSON.stringify(refreshValues)) {
-			return
-		}
-
-		prevRefreshValuesRef.current = refreshValues
-		debouncedRefreshModels()
-	}, [debouncedRefreshModels, refreshValues])
-
-	useEffect(() => setValue(selectedModelId), [selectedModelId])
+	}, [apiConfiguration, defaultModelId, modelIdKey, onSelect])
 
 	return (
 		<>
 			<div className="font-semibold">Model</div>
-			<Popover open={open} onOpenChange={setOpen}>
-				<PopoverTrigger asChild>
-					<Button variant="combobox" role="combobox" aria-expanded={open} className="w-full justify-between">
-						{value ?? "Select model..."}
-						<CaretSortIcon className="opacity-50" />
-					</Button>
-				</PopoverTrigger>
-				<PopoverContent align="start" className="p-0">
-					<Command>
-						<CommandInput placeholder="Search model..." className="h-9" />
-						<CommandList>
-							<CommandEmpty>No model found.</CommandEmpty>
-							<CommandGroup>
-								{modelIds.map((model) => (
-									<CommandItem key={model} value={model} onSelect={onSelect}>
-										{model}
-										<CheckIcon
-											className={cn("ml-auto", value === model ? "opacity-100" : "opacity-0")}
-										/>
-									</CommandItem>
-								))}
-							</CommandGroup>
-							{allowCustomModel && (
-								<CommandGroup heading="Custom">
-									<CommandItem
-										onSelect={() => {
-											setIsCustomModel(true)
-											setOpen(false)
-										}}>
-										+ Add custom model
-									</CommandItem>
-								</CommandGroup>
-							)}
-						</CommandList>
-					</Command>
-				</PopoverContent>
-			</Popover>
-			{selectedModelId && selectedModelInfo && (
-				<ModelInfoView
-					selectedModelId={selectedModelId}
-					modelInfo={selectedModelInfo}
-					isDescriptionExpanded={isDescriptionExpanded}
-					setIsDescriptionExpanded={setIsDescriptionExpanded}
+			<Combobox
+				style={errorMessage ? { "--color-vscode-dropdown-border": "var(--color-vscode-errorForeground)" } : {}}
+				type="single"
+				inputValue={apiConfiguration[modelIdKey]}
+				onInputValueChange={onSelect}>
+				<ComboboxInput
+					className="border-vscode-errorForeground tefat"
+					placeholder="Search model..."
+					data-testid="model-input"
+					aria-errormessage={errorMessage}
 				/>
+				<ComboboxContent>
+					<ComboboxEmpty>No model found.</ComboboxEmpty>
+					{modelIds.map((model) => (
+						<ComboboxItem key={model} value={model}>
+							{model}
+						</ComboboxItem>
+					))}
+				</ComboboxContent>
+			</Combobox>
+
+			{errorMessage ? (
+				<ApiErrorMessage errorMessage={errorMessage}>
+					<p
+						style={{
+							fontSize: "12px",
+							marginTop: 3,
+							color: "var(--vscode-descriptionForeground)",
+						}}>
+						<span style={{ color: "var(--vscode-errorForeground)" }}>
+							<span style={{ fontWeight: 500 }}>Note:</span> Roo Code uses complex prompts and works best
+							with Claude models. Less capable models may not work as expected.
+						</span>
+					</p>
+				</ApiErrorMessage>
+			) : (
+				selectedModelId &&
+				selectedModelInfo && (
+					<ModelInfoView
+						selectedModelId={selectedModelId}
+						modelInfo={selectedModelInfo}
+						isDescriptionExpanded={isDescriptionExpanded}
+						setIsDescriptionExpanded={setIsDescriptionExpanded}
+					/>
+				)
 			)}
 			<p>
 				The extension automatically fetches the latest list of models available on{" "}
@@ -197,28 +127,6 @@ export const ModelPicker = ({
 				<VSCodeLink onClick={() => onSelect(recommendedModel)}>{recommendedModel}.</VSCodeLink>
 				You can also try searching "free" for no-cost options currently available.
 			</p>
-			{allowCustomModel && isCustomModel && (
-				<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
-					<div className="bg-[var(--vscode-editor-background)] p-6 rounded-lg w-96">
-						<h3 className="text-lg font-semibold mb-4">Add Custom Model</h3>
-						<input
-							type="text"
-							className="w-full p-2 mb-4 bg-[var(--vscode-input-background)] text-[var(--vscode-input-foreground)] border border-[var(--vscode-input-border)] rounded"
-							placeholder="Enter model ID"
-							value={customModelId}
-							onChange={(e) => setCustomModelId(e.target.value)}
-						/>
-						<div className="flex justify-end gap-2">
-							<Button variant="secondary" onClick={() => setIsCustomModel(false)}>
-								Cancel
-							</Button>
-							<Button onClick={() => onSelectCustomModel(customModelId)} disabled={!customModelId.trim()}>
-								Add
-							</Button>
-						</div>
-					</div>
-				</div>
-			)}
 		</>
 	)
 }

+ 0 - 27
webview-ui/src/components/settings/OpenAiModelPicker.tsx

@@ -1,27 +0,0 @@
-import React from "react"
-import { useExtensionState } from "../../context/ExtensionStateContext"
-import { ModelPicker } from "./ModelPicker"
-
-const OpenAiModelPicker: React.FC = () => {
-	const { apiConfiguration } = useExtensionState()
-
-	return (
-		<ModelPicker
-			defaultModelId={apiConfiguration?.openAiModelId || ""}
-			modelsKey="openAiModels"
-			configKey="openAiModelId"
-			infoKey="openAiModelInfo"
-			refreshMessageType="refreshOpenAiModels"
-			refreshValues={{
-				baseUrl: apiConfiguration?.openAiBaseUrl,
-				apiKey: apiConfiguration?.openAiApiKey,
-			}}
-			serviceName="OpenAI"
-			serviceUrl="https://platform.openai.com"
-			recommendedModel="gpt-4-turbo-preview"
-			allowCustomModel={true}
-		/>
-	)
-}
-
-export default OpenAiModelPicker

+ 0 - 15
webview-ui/src/components/settings/OpenRouterModelPicker.tsx

@@ -1,15 +0,0 @@
-import { ModelPicker } from "./ModelPicker"
-import { openRouterDefaultModelId } from "../../../../src/shared/api"
-
-export const OpenRouterModelPicker = () => (
-	<ModelPicker
-		defaultModelId={openRouterDefaultModelId}
-		modelsKey="openRouterModels"
-		configKey="openRouterModelId"
-		infoKey="openRouterModelInfo"
-		refreshMessageType="refreshOpenRouterModels"
-		serviceName="OpenRouter"
-		serviceUrl="https://openrouter.ai/models"
-		recommendedModel="anthropic/claude-3.7-sonnet"
-	/>
-)

+ 0 - 22
webview-ui/src/components/settings/RequestyModelPicker.tsx

@@ -1,22 +0,0 @@
-import { ModelPicker } from "./ModelPicker"
-import { requestyDefaultModelId } from "../../../../src/shared/api"
-import { useExtensionState } from "@/context/ExtensionStateContext"
-
-export const RequestyModelPicker = () => {
-	const { apiConfiguration } = useExtensionState()
-	return (
-		<ModelPicker
-			defaultModelId={requestyDefaultModelId}
-			modelsKey="requestyModels"
-			configKey="requestyModelId"
-			infoKey="requestyModelInfo"
-			refreshMessageType="refreshRequestyModels"
-			refreshValues={{
-				apiKey: apiConfiguration?.requestyApiKey,
-			}}
-			serviceName="Requesty"
-			serviceUrl="https://requesty.ai"
-			recommendedModel="anthropic/claude-3-7-sonnet-latest"
-		/>
-	)
-}

+ 16 - 43
webview-ui/src/components/settings/SettingsView.tsx

@@ -1,6 +1,6 @@
-import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"
+import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"
 import { VSCodeButton, VSCodeCheckbox, VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
-import { Dropdown, type DropdownOption } from "vscrui"
+import { Button, Dropdown, type DropdownOption } from "vscrui"
 
 import {
 	AlertDialog,
@@ -14,7 +14,6 @@ import {
 } from "@/components/ui"
 
 import { vscode } from "../../utils/vscode"
-import { validateApiConfiguration, validateModelId } from "../../utils/validate"
 import { ExtensionStateContextType, useExtensionState } from "../../context/ExtensionStateContext"
 import { EXPERIMENT_IDS, experimentConfigsMap, ExperimentId } from "../../../../src/shared/experiments"
 import { ApiConfiguration } from "../../../../src/shared/api"
@@ -33,19 +32,17 @@ export interface SettingsViewRef {
 
 const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone }, ref) => {
 	const extensionState = useExtensionState()
-	const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
-	const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined)
 	const [commandInput, setCommandInput] = useState("")
 	const [isDiscardDialogShow, setDiscardDialogShow] = useState(false)
 	const [cachedState, setCachedState] = useState(extensionState)
 	const [isChangeDetected, setChangeDetected] = useState(false)
 	const prevApiConfigName = useRef(extensionState.currentApiConfigName)
 	const confirmDialogHandler = useRef<() => void>()
+	const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined)
 
 	// TODO: Reduce WebviewMessage/ExtensionState complexity
 	const { currentApiConfigName } = extensionState
 	const {
-		apiConfiguration,
 		alwaysAllowReadOnly,
 		allowedCommands,
 		alwaysAllowBrowser,
@@ -69,6 +66,9 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 		terminalOutputLineLimit,
 		writeDelayMs,
 	} = cachedState
+	
+	//Make sure apiConfiguration is initialized and managed by SettingsView
+	const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
 
 	useEffect(() => {
 		// Update only when currentApiConfigName is changed
@@ -133,20 +133,9 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 			}
 		})
 	}, [])
-
+	const isSettingValid = !errorMessage
 	const handleSubmit = () => {
-		const apiValidationResult = validateApiConfiguration(apiConfiguration)
-
-		const modelIdValidationResult = validateModelId(
-			apiConfiguration,
-			extensionState.glamaModels,
-			extensionState.openRouterModels,
-		)
-
-		setApiErrorMessage(apiValidationResult)
-		setModelIdErrorMessage(modelIdValidationResult)
-
-		if (!apiValidationResult && !modelIdValidationResult) {
+		if (isSettingValid) {
 			vscode.postMessage({ type: "alwaysAllowReadOnly", bool: alwaysAllowReadOnly })
 			vscode.postMessage({ type: "alwaysAllowWrite", bool: alwaysAllowWrite })
 			vscode.postMessage({ type: "alwaysAllowExecute", bool: alwaysAllowExecute })
@@ -175,23 +164,6 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 		}
 	}
 
-	useEffect(() => {
-		setApiErrorMessage(undefined)
-		setModelIdErrorMessage(undefined)
-	}, [apiConfiguration])
-
-	// Initial validation on mount
-	useEffect(() => {
-		const apiValidationResult = validateApiConfiguration(apiConfiguration)
-		const modelIdValidationResult = validateModelId(
-			apiConfiguration,
-			extensionState.glamaModels,
-			extensionState.openRouterModels,
-		)
-		setApiErrorMessage(apiValidationResult)
-		setModelIdErrorMessage(modelIdValidationResult)
-	}, [apiConfiguration, extensionState.glamaModels, extensionState.openRouterModels])
-
 	const checkUnsaveChanges = useCallback(
 		(then: () => void) => {
 			if (isChangeDetected) {
@@ -285,13 +257,14 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 						justifyContent: "space-between",
 						gap: "6px",
 					}}>
-					<VSCodeButton
-						appearance="primary"
-						title={isChangeDetected ? "Save changes" : "Nothing changed"}
+					<Button
+						appearance={isSettingValid ? "primary" : "secondary"}
+						className={!isSettingValid ? "!border-vscode-errorForeground" : ""}
+						title={!isSettingValid ? errorMessage : isChangeDetected ? "Save changes" : "Nothing changed"}
 						onClick={handleSubmit}
-						disabled={!isChangeDetected}>
+						disabled={!isChangeDetected || !isSettingValid}>
 						Save
-					</VSCodeButton>
+					</Button>
 					<VSCodeButton
 						appearance="secondary"
 						title="Discard unsaved changes and close settings panel"
@@ -342,8 +315,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 							uriScheme={extensionState.uriScheme}
 							apiConfiguration={apiConfiguration}
 							setApiConfigurationField={setApiConfigurationField}
-							apiErrorMessage={apiErrorMessage}
-							modelIdErrorMessage={modelIdErrorMessage}
+							errorMessage={errorMessage}
+							setErrorMessage={setErrorMessage}
 						/>
 					</div>
 				</div>

+ 0 - 15
webview-ui/src/components/settings/UnboundModelPicker.tsx

@@ -1,15 +0,0 @@
-import { ModelPicker } from "./ModelPicker"
-import { unboundDefaultModelId } from "../../../../src/shared/api"
-
-export const UnboundModelPicker = () => (
-	<ModelPicker
-		defaultModelId={unboundDefaultModelId}
-		modelsKey="unboundModels"
-		configKey="unboundModelId"
-		infoKey="unboundModelInfo"
-		refreshMessageType="refreshUnboundModels"
-		serviceName="Unbound"
-		serviceUrl="https://api.getunbound.ai/models"
-		recommendedModel={unboundDefaultModelId}
-	/>
-)

+ 4 - 0
webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx

@@ -51,6 +51,8 @@ describe("ApiOptions", () => {
 		render(
 			<ExtensionStateContextProvider>
 				<ApiOptions
+					errorMessage={undefined}
+					setErrorMessage={() => {}}
 					uriScheme={undefined}
 					apiConfiguration={{}}
 					setApiConfigurationField={() => {}}
@@ -69,4 +71,6 @@ describe("ApiOptions", () => {
 		renderApiOptions({ fromWelcomeView: true })
 		expect(screen.queryByTestId("temperature-control")).not.toBeInTheDocument()
 	})
+
+	//TODO: More test cases needed
 })

+ 27 - 32
webview-ui/src/components/settings/__tests__/ModelPicker.test.tsx

@@ -3,7 +3,6 @@
 import { screen, fireEvent, render } from "@testing-library/react"
 import { act } from "react"
 import { ModelPicker } from "../ModelPicker"
-import { useExtensionState } from "../../../context/ExtensionStateContext"
 
 jest.mock("../../../context/ExtensionStateContext", () => ({
 	useExtensionState: jest.fn(),
@@ -20,36 +19,40 @@ global.ResizeObserver = MockResizeObserver
 Element.prototype.scrollIntoView = jest.fn()
 
 describe("ModelPicker", () => {
-	const mockOnUpdateApiConfig = jest.fn()
-	const mockSetApiConfiguration = jest.fn()
-
+	const mockSetApiConfigurationField = jest.fn()
+	const modelInfo = {
+		maxTokens: 8192,
+		contextWindow: 200_000,
+		supportsImages: true,
+		supportsComputerUse: true,
+		supportsPromptCache: true,
+		inputPrice: 3.0,
+		outputPrice: 15.0,
+		cacheWritesPrice: 3.75,
+		cacheReadsPrice: 0.3,
+	}
+	const mockModels = {
+		model1: { name: "Model 1", description: "Test model 1", ...modelInfo },
+		model2: { name: "Model 2", description: "Test model 2", ...modelInfo },
+	}
 	const defaultProps = {
+		apiConfiguration: {},
 		defaultModelId: "model1",
-		modelsKey: "glamaModels" as const,
-		configKey: "glamaModelId" as const,
-		infoKey: "glamaModelInfo" as const,
-		refreshMessageType: "refreshGlamaModels" as const,
+		defaultModelInfo: modelInfo,
+		modelIdKey: "glamaModelId" as const,
+		modelInfoKey: "glamaModelInfo" as const,
 		serviceName: "Test Service",
 		serviceUrl: "https://test.service",
 		recommendedModel: "recommended-model",
-	}
-
-	const mockModels = {
-		model1: { name: "Model 1", description: "Test model 1" },
-		model2: { name: "Model 2", description: "Test model 2" },
+		models: mockModels,
+		setApiConfigurationField: mockSetApiConfigurationField,
 	}
 
 	beforeEach(() => {
 		jest.clearAllMocks()
-		;(useExtensionState as jest.Mock).mockReturnValue({
-			apiConfiguration: {},
-			setApiConfiguration: mockSetApiConfiguration,
-			glamaModels: mockModels,
-			onUpdateApiConfig: mockOnUpdateApiConfig,
-		})
 	})
 
-	it("calls onUpdateApiConfig when a model is selected", async () => {
+	it("calls setApiConfigurationField when a model is selected", async () => {
 		await act(async () => {
 			render(<ModelPicker {...defaultProps} />)
 		})
@@ -67,20 +70,12 @@ describe("ModelPicker", () => {
 
 		await act(async () => {
 			// Find and click the model item by its value.
-			const modelItem = screen.getByRole("option", { name: "model2" })
-			fireEvent.click(modelItem)
+			const modelItem = screen.getByTestId("model-input")
+			fireEvent.input(modelItem, { target: { value: "model2" } })
 		})
 
 		// Verify the API config was updated.
-		expect(mockSetApiConfiguration).toHaveBeenCalledWith({
-			glamaModelId: "model2",
-			glamaModelInfo: mockModels["model2"],
-		})
-
-		// Verify onUpdateApiConfig was called with the new config.
-		expect(mockOnUpdateApiConfig).toHaveBeenCalledWith({
-			glamaModelId: "model2",
-			glamaModelInfo: mockModels["model2"],
-		})
+		expect(mockSetApiConfigurationField).toHaveBeenCalledWith(defaultProps.modelIdKey, "model2")
+		expect(mockSetApiConfigurationField).toHaveBeenCalledWith(defaultProps.modelInfoKey, mockModels.model2)
 	})
 })

+ 522 - 0
webview-ui/src/components/ui/combobox-primitive.tsx

@@ -0,0 +1,522 @@
+/* eslint-disable react/jsx-pascal-case */
+"use client"
+
+import * as React from "react"
+import { composeEventHandlers } from "@radix-ui/primitive"
+import { useComposedRefs } from "@radix-ui/react-compose-refs"
+import * as PopoverPrimitive from "@radix-ui/react-popover"
+import { Primitive } from "@radix-ui/react-primitive"
+import * as RovingFocusGroupPrimitive from "@radix-ui/react-roving-focus"
+import { useControllableState } from "@radix-ui/react-use-controllable-state"
+import { Command as CommandPrimitive } from "cmdk"
+
+export type ComboboxContextProps = {
+	inputValue: string
+	onInputValueChange: (inputValue: string, reason: "inputChange" | "itemSelect" | "clearClick") => void
+	onInputBlur?: (e: React.FocusEvent<HTMLInputElement, Element>) => void
+	open: boolean
+	onOpenChange: (open: boolean) => void
+	currentTabStopId: string | null
+	onCurrentTabStopIdChange: (currentTabStopId: string | null) => void
+	inputRef: React.RefObject<HTMLInputElement>
+	tagGroupRef: React.RefObject<React.ElementRef<typeof RovingFocusGroupPrimitive.Root>>
+	disabled?: boolean
+	required?: boolean
+} & (
+	| Required<Pick<ComboboxSingleProps, "type" | "value" | "onValueChange">>
+	| Required<Pick<ComboboxMultipleProps, "type" | "value" | "onValueChange">>
+)
+
+const ComboboxContext = React.createContext<ComboboxContextProps>({
+	type: "single",
+	value: "",
+	onValueChange: () => {},
+	inputValue: "",
+	onInputValueChange: () => {},
+	onInputBlur: () => {},
+	open: false,
+	onOpenChange: () => {},
+	currentTabStopId: null,
+	onCurrentTabStopIdChange: () => {},
+	inputRef: { current: null },
+	tagGroupRef: { current: null },
+	disabled: false,
+	required: false,
+})
+
+export const useComboboxContext = () => React.useContext(ComboboxContext)
+
+export type ComboboxType = "single" | "multiple"
+
+export interface ComboboxBaseProps
+	extends React.ComponentProps<typeof PopoverPrimitive.Root>,
+		Omit<React.ComponentProps<typeof CommandPrimitive>, "value" | "defaultValue" | "onValueChange"> {
+	type?: ComboboxType | undefined
+	inputValue?: string
+	defaultInputValue?: string
+	onInputValueChange?: (inputValue: string, reason: "inputChange" | "itemSelect" | "clearClick") => void
+	onInputBlur?: (e: React.FocusEvent<HTMLInputElement, Element>) => void
+	disabled?: boolean
+	required?: boolean
+}
+
+export type ComboboxValue<T extends ComboboxType = "single"> = T extends "single"
+	? string
+	: T extends "multiple"
+		? string[]
+		: never
+
+export interface ComboboxSingleProps {
+	type: "single"
+	value?: string
+	defaultValue?: string
+	onValueChange?: (value: string) => void
+}
+
+export interface ComboboxMultipleProps {
+	type: "multiple"
+	value?: string[]
+	defaultValue?: string[]
+	onValueChange?: (value: string[]) => void
+}
+
+export type ComboboxProps = ComboboxBaseProps & (ComboboxSingleProps | ComboboxMultipleProps)
+
+export const Combobox = React.forwardRef(
+	<T extends ComboboxType = "single">(
+		{
+			type = "single" as T,
+			open: openProp,
+			onOpenChange,
+			defaultOpen,
+			modal,
+			children,
+			value: valueProp,
+			defaultValue,
+			onValueChange,
+			inputValue: inputValueProp,
+			defaultInputValue,
+			onInputValueChange,
+			onInputBlur,
+			disabled,
+			required,
+			...props
+		}: ComboboxProps,
+		ref: React.ForwardedRef<React.ElementRef<typeof CommandPrimitive>>,
+	) => {
+		const [value = type === "multiple" ? [] : "", setValue] = useControllableState<ComboboxValue<T>>({
+			prop: valueProp as ComboboxValue<T>,
+			defaultProp: defaultValue as ComboboxValue<T>,
+			onChange: onValueChange as (value: ComboboxValue<T>) => void,
+		})
+		const [inputValue = "", setInputValue] = useControllableState({
+			prop: inputValueProp,
+			defaultProp: defaultInputValue,
+		})
+		const [open = false, setOpen] = useControllableState({
+			prop: openProp,
+			defaultProp: defaultOpen,
+			onChange: onOpenChange,
+		})
+		const [currentTabStopId, setCurrentTabStopId] = React.useState<string | null>(null)
+		const inputRef = React.useRef<HTMLInputElement>(null)
+		const tagGroupRef = React.useRef<React.ElementRef<typeof RovingFocusGroupPrimitive.Root>>(null)
+
+		const handleInputValueChange: ComboboxContextProps["onInputValueChange"] = React.useCallback(
+			(inputValue, reason) => {
+				setInputValue(inputValue)
+				onInputValueChange?.(inputValue, reason)
+			},
+			[setInputValue, onInputValueChange],
+		)
+
+		return (
+			<ComboboxContext.Provider
+				value={
+					{
+						type,
+						value,
+						onValueChange: setValue,
+						inputValue,
+						onInputValueChange: handleInputValueChange,
+						onInputBlur,
+						open,
+						onOpenChange: setOpen,
+						currentTabStopId,
+						onCurrentTabStopIdChange: setCurrentTabStopId,
+						inputRef,
+						tagGroupRef,
+						disabled,
+						required,
+					} as ComboboxContextProps
+				}>
+				<PopoverPrimitive.Root open={open} onOpenChange={setOpen} modal={modal}>
+					<CommandPrimitive ref={ref} {...props}>
+						{children}
+						{!open && <CommandPrimitive.List aria-hidden hidden />}
+					</CommandPrimitive>
+				</PopoverPrimitive.Root>
+			</ComboboxContext.Provider>
+		)
+	},
+)
+Combobox.displayName = "Combobox"
+
+export const ComboboxTagGroup = React.forwardRef<
+	React.ElementRef<typeof RovingFocusGroupPrimitive.Root>,
+	React.ComponentPropsWithoutRef<typeof RovingFocusGroupPrimitive.Root>
+>((props, ref) => {
+	const { currentTabStopId, onCurrentTabStopIdChange, tagGroupRef, type } = useComboboxContext()
+
+	if (type !== "multiple") {
+		throw new Error('<ComboboxTagGroup> should only be used when type is "multiple"')
+	}
+
+	const composedRefs = useComposedRefs(ref, tagGroupRef)
+
+	return (
+		<RovingFocusGroupPrimitive.Root
+			ref={composedRefs}
+			tabIndex={-1}
+			currentTabStopId={currentTabStopId}
+			onCurrentTabStopIdChange={onCurrentTabStopIdChange}
+			onBlur={() => onCurrentTabStopIdChange(null)}
+			{...props}
+		/>
+	)
+})
+ComboboxTagGroup.displayName = "ComboboxTagGroup"
+
+export interface ComboboxTagGroupItemProps
+	extends React.ComponentPropsWithoutRef<typeof RovingFocusGroupPrimitive.Item> {
+	value: string
+	disabled?: boolean
+}
+
+const ComboboxTagGroupItemContext = React.createContext<Pick<ComboboxTagGroupItemProps, "value" | "disabled">>({
+	value: "",
+	disabled: false,
+})
+
+const useComboboxTagGroupItemContext = () => React.useContext(ComboboxTagGroupItemContext)
+
+export const ComboboxTagGroupItem = React.forwardRef<
+	React.ElementRef<typeof RovingFocusGroupPrimitive.Item>,
+	ComboboxTagGroupItemProps
+>(({ onClick, onKeyDown, value: valueProp, disabled, ...props }, ref) => {
+	const { value, onValueChange, inputRef, currentTabStopId, type } = useComboboxContext()
+
+	if (type !== "multiple") {
+		throw new Error('<ComboboxTagGroupItem> should only be used when type is "multiple"')
+	}
+
+	const lastItemValue = value.at(-1)
+
+	return (
+		<ComboboxTagGroupItemContext.Provider value={{ value: valueProp, disabled }}>
+			<RovingFocusGroupPrimitive.Item
+				ref={ref}
+				onKeyDown={composeEventHandlers(onKeyDown, (event) => {
+					if (event.key === "Escape") {
+						inputRef.current?.focus()
+					}
+					if (event.key === "ArrowUp" || event.key === "ArrowDown") {
+						event.preventDefault()
+						inputRef.current?.focus()
+					}
+					if (event.key === "ArrowRight" && currentTabStopId === lastItemValue) {
+						inputRef.current?.focus()
+					}
+					if (event.key === "Backspace" || event.key === "Delete") {
+						onValueChange(value.filter((v) => v !== currentTabStopId))
+						inputRef.current?.focus()
+					}
+				})}
+				onClick={composeEventHandlers(onClick, () => disabled && inputRef.current?.focus())}
+				tabStopId={valueProp}
+				focusable={!disabled}
+				data-disabled={disabled}
+				active={valueProp === lastItemValue}
+				{...props}
+			/>
+		</ComboboxTagGroupItemContext.Provider>
+	)
+})
+ComboboxTagGroupItem.displayName = "ComboboxTagGroupItem"
+
+export const ComboboxTagGroupItemRemove = React.forwardRef<
+	React.ElementRef<typeof Primitive.button>,
+	React.ComponentPropsWithoutRef<typeof Primitive.button>
+>(({ onClick, ...props }, ref) => {
+	const { value, onValueChange, type } = useComboboxContext()
+
+	if (type !== "multiple") {
+		throw new Error('<ComboboxTagGroupItemRemove> should only be used when type is "multiple"')
+	}
+
+	const { value: valueProp, disabled } = useComboboxTagGroupItemContext()
+
+	return (
+		<Primitive.button
+			ref={ref}
+			aria-hidden
+			tabIndex={-1}
+			disabled={disabled}
+			onClick={composeEventHandlers(onClick, () => onValueChange(value.filter((v) => v !== valueProp)))}
+			{...props}
+		/>
+	)
+})
+ComboboxTagGroupItemRemove.displayName = "ComboboxTagGroupItemRemove"
+
+export const ComboboxInput = React.forwardRef<
+	React.ElementRef<typeof CommandPrimitive.Input>,
+	Omit<React.ComponentProps<typeof CommandPrimitive.Input>, "value" | "onValueChange">
+>(({ onKeyDown, onMouseDown, onFocus, onBlur, ...props }, ref) => {
+	const {
+		type,
+		inputValue,
+		onInputValueChange,
+		onInputBlur,
+		open,
+		onOpenChange,
+		value,
+		onValueChange,
+		inputRef,
+		disabled,
+		required,
+		tagGroupRef,
+	} = useComboboxContext()
+
+	const composedRefs = useComposedRefs(ref, inputRef)
+
+	return (
+		<CommandPrimitive.Input
+			ref={composedRefs}
+			disabled={disabled}
+			required={required}
+			value={inputValue}
+			onValueChange={(search) => {
+				if (!open) {
+					onOpenChange(true)
+				}
+				// Schedule input value change to the next tick.
+				setTimeout(() => onInputValueChange(search, "inputChange"))
+				if (!search && type === "single") {
+					onValueChange("")
+				}
+			}}
+			onKeyDown={composeEventHandlers(onKeyDown, (event) => {
+				if (event.key === "ArrowUp" || event.key === "ArrowDown") {
+					if (!open) {
+						event.preventDefault()
+						onOpenChange(true)
+					}
+				}
+				if (type !== "multiple") {
+					return
+				}
+				if (event.key === "ArrowLeft" && !inputValue && value.length) {
+					tagGroupRef.current?.focus()
+				}
+				if (event.key === "Backspace" && !inputValue) {
+					onValueChange(value.slice(0, -1))
+				}
+			})}
+			onMouseDown={composeEventHandlers(onMouseDown, () => onOpenChange(!!inputValue || !open))}
+			onFocus={composeEventHandlers(onFocus, () => onOpenChange(true))}
+			onBlur={composeEventHandlers(onBlur, (event) => {
+				if (!event.relatedTarget?.hasAttribute("cmdk-list")) {
+					onInputBlur?.(event)
+				}
+			})}
+			{...props}
+		/>
+	)
+})
+ComboboxInput.displayName = "ComboboxInput"
+
+export const ComboboxClear = React.forwardRef<
+	React.ElementRef<typeof Primitive.button>,
+	React.ComponentPropsWithoutRef<typeof Primitive.button>
+>(({ onClick, ...props }, ref) => {
+	const { value, onValueChange, inputValue, onInputValueChange, type } = useComboboxContext()
+
+	const isValueEmpty = type === "single" ? !value : !value.length
+
+	return (
+		<Primitive.button
+			ref={ref}
+			disabled={isValueEmpty && !inputValue}
+			onClick={composeEventHandlers(onClick, () => {
+				if (type === "single") {
+					onValueChange("")
+				} else {
+					onValueChange([])
+				}
+				onInputValueChange("", "clearClick")
+			})}
+			{...props}
+		/>
+	)
+})
+ComboboxClear.displayName = "ComboboxClear"
+
+export const ComboboxTrigger = PopoverPrimitive.Trigger
+
+export const ComboboxAnchor = PopoverPrimitive.Anchor
+
+export const ComboboxPortal = PopoverPrimitive.Portal
+
+export const ComboboxContent = React.forwardRef<
+	React.ElementRef<typeof PopoverPrimitive.Content>,
+	React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
+>(({ children, onOpenAutoFocus, onInteractOutside, ...props }, ref) => (
+	<PopoverPrimitive.Content
+		asChild
+		ref={ref}
+		onOpenAutoFocus={composeEventHandlers(onOpenAutoFocus, (event) => event.preventDefault())}
+		onCloseAutoFocus={composeEventHandlers(onOpenAutoFocus, (event) => event.preventDefault())}
+		onInteractOutside={composeEventHandlers(onInteractOutside, (event) => {
+			if (event.target instanceof Element && event.target.hasAttribute("cmdk-input")) {
+				event.preventDefault()
+			}
+		})}
+		{...props}>
+		<CommandPrimitive.List>{children}</CommandPrimitive.List>
+	</PopoverPrimitive.Content>
+))
+ComboboxContent.displayName = "ComboboxContent"
+
+export const ComboboxEmpty = CommandPrimitive.Empty
+
+export const ComboboxLoading = CommandPrimitive.Loading
+
+export interface ComboboxItemProps extends Omit<React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>, "value"> {
+	value: string
+}
+
+const ComboboxItemContext = React.createContext({ isSelected: false })
+
+const useComboboxItemContext = () => React.useContext(ComboboxItemContext)
+
+const findComboboxItemText = (children: React.ReactNode) => {
+	let text = ""
+
+	React.Children.forEach(children, (child) => {
+		if (text) {
+			return
+		}
+
+		if (React.isValidElement<{ children: React.ReactNode }>(child)) {
+			if (child.type === ComboboxItemText) {
+				text = child.props.children as string
+			} else {
+				text = findComboboxItemText(child.props.children)
+			}
+		}
+	})
+
+	return text
+}
+
+export const ComboboxItem = React.forwardRef<React.ElementRef<typeof CommandPrimitive.Item>, ComboboxItemProps>(
+	({ value: valueProp, children, onMouseDown, ...props }, ref) => {
+		const { type, value, onValueChange, onInputValueChange, onOpenChange } = useComboboxContext()
+
+		const inputValue = React.useMemo(() => findComboboxItemText(children), [children])
+
+		const isSelected = type === "single" ? value === valueProp : value.includes(valueProp)
+
+		return (
+			<ComboboxItemContext.Provider value={{ isSelected }}>
+				<CommandPrimitive.Item
+					ref={ref}
+					onMouseDown={composeEventHandlers(onMouseDown, (event) => event.preventDefault())}
+					onSelect={() => {
+						if (type === "multiple") {
+							onValueChange(
+								value.includes(valueProp)
+									? value.filter((v) => v !== valueProp)
+									: [...value, valueProp],
+							)
+							onInputValueChange("", "itemSelect")
+						} else {
+							onValueChange(valueProp)
+							onInputValueChange(inputValue, "itemSelect")
+							// Schedule open change to the next tick.
+							setTimeout(() => onOpenChange(false))
+						}
+					}}
+					value={inputValue}
+					{...props}>
+					{children}
+				</CommandPrimitive.Item>
+			</ComboboxItemContext.Provider>
+		)
+	},
+)
+ComboboxItem.displayName = "ComboboxItem"
+
+export const ComboboxItemIndicator = React.forwardRef<
+	React.ElementRef<typeof Primitive.span>,
+	React.ComponentPropsWithoutRef<typeof Primitive.span>
+>((props, ref) => {
+	const { isSelected } = useComboboxItemContext()
+
+	if (!isSelected) {
+		return null
+	}
+
+	return <Primitive.span ref={ref} aria-hidden {...props} />
+})
+ComboboxItemIndicator.displayName = "ComboboxItemIndicator"
+
+export interface ComboboxItemTextProps extends React.ComponentPropsWithoutRef<typeof React.Fragment> {
+	children: string
+}
+
+export const ComboboxItemText = (props: ComboboxItemTextProps) => <React.Fragment {...props} />
+ComboboxItemText.displayName = "ComboboxItemText"
+
+export const ComboboxGroup = CommandPrimitive.Group
+
+export const ComboboxSeparator = CommandPrimitive.Separator
+
+const Root = Combobox
+const TagGroup = ComboboxTagGroup
+const TagGroupItem = ComboboxTagGroupItem
+const TagGroupItemRemove = ComboboxTagGroupItemRemove
+const Input = ComboboxInput
+const Clear = ComboboxClear
+const Trigger = ComboboxTrigger
+const Anchor = ComboboxAnchor
+const Portal = ComboboxPortal
+const Content = ComboboxContent
+const Empty = ComboboxEmpty
+const Loading = ComboboxLoading
+const Item = ComboboxItem
+const ItemIndicator = ComboboxItemIndicator
+const ItemText = ComboboxItemText
+const Group = ComboboxGroup
+const Separator = ComboboxSeparator
+
+export {
+	Root,
+	TagGroup,
+	TagGroupItem,
+	TagGroupItemRemove,
+	Input,
+	Clear,
+	Trigger,
+	Anchor,
+	Portal,
+	Content,
+	Empty,
+	Loading,
+	Item,
+	ItemIndicator,
+	ItemText,
+	Group,
+	Separator,
+}

+ 177 - 0
webview-ui/src/components/ui/combobox.tsx

@@ -0,0 +1,177 @@
+"use client"
+
+import * as React from "react"
+import { Slottable } from "@radix-ui/react-slot"
+import { cva } from "class-variance-authority"
+import { Check, ChevronsUpDown, Loader, X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import * as ComboboxPrimitive from "@/components/ui/combobox-primitive"
+import { badgeVariants } from "@/components/ui/badge"
+// import * as ComboboxPrimitive from "@/registry/default/ui/combobox-primitive"
+import {
+	InputBase,
+	InputBaseAdornmentButton,
+	InputBaseControl,
+	InputBaseFlexWrapper,
+	InputBaseInput,
+} from "@/components/ui/input-base"
+
+export const Combobox = ComboboxPrimitive.Root
+
+const ComboboxInputBase = React.forwardRef<
+	React.ElementRef<typeof InputBase>,
+	React.ComponentPropsWithoutRef<typeof InputBase>
+>(({ children, ...props }, ref) => (
+	<ComboboxPrimitive.Anchor asChild>
+		<InputBase ref={ref} {...props}>
+			{children}
+			<ComboboxPrimitive.Clear asChild>
+				<InputBaseAdornmentButton>
+					<X />
+				</InputBaseAdornmentButton>
+			</ComboboxPrimitive.Clear>
+			<ComboboxPrimitive.Trigger asChild>
+				<InputBaseAdornmentButton>
+					<ChevronsUpDown />
+				</InputBaseAdornmentButton>
+			</ComboboxPrimitive.Trigger>
+		</InputBase>
+	</ComboboxPrimitive.Anchor>
+))
+ComboboxInputBase.displayName = "ComboboxInputBase"
+
+export const ComboboxInput = React.forwardRef<
+	React.ElementRef<typeof ComboboxPrimitive.Input>,
+	React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Input>
+>((props, ref) => (
+	<ComboboxInputBase>
+		<InputBaseControl>
+			<ComboboxPrimitive.Input asChild>
+				<InputBaseInput ref={ref} {...props} />
+			</ComboboxPrimitive.Input>
+		</InputBaseControl>
+	</ComboboxInputBase>
+))
+ComboboxInput.displayName = "ComboboxInput"
+
+export const ComboboxTagsInput = React.forwardRef<
+	React.ElementRef<typeof ComboboxPrimitive.Input>,
+	React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Input>
+>(({ children, ...props }, ref) => (
+	<ComboboxInputBase>
+		<ComboboxPrimitive.ComboboxTagGroup asChild>
+			<InputBaseFlexWrapper className="flex items-center gap-2">
+				{children}
+				<InputBaseControl>
+					<ComboboxPrimitive.Input asChild>
+						<InputBaseInput ref={ref} {...props} />
+					</ComboboxPrimitive.Input>
+				</InputBaseControl>
+			</InputBaseFlexWrapper>
+		</ComboboxPrimitive.ComboboxTagGroup>
+	</ComboboxInputBase>
+))
+ComboboxTagsInput.displayName = "ComboboxTagsInput"
+
+export const ComboboxTag = React.forwardRef<
+	React.ElementRef<typeof ComboboxPrimitive.ComboboxTagGroupItem>,
+	React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.ComboboxTagGroupItem>
+>(({ children, className, ...props }, ref) => (
+	<ComboboxPrimitive.ComboboxTagGroupItem
+		ref={ref}
+		className={cn(
+			badgeVariants({ variant: "outline" }),
+			"group gap-1 pr-1.5 data-[disabled]:opacity-50",
+			className,
+		)}
+		{...props}>
+		<Slottable>{children}</Slottable>
+		<ComboboxPrimitive.ComboboxTagGroupItemRemove className="group-data-[disabled]:pointer-events-none">
+			<X className="size-4" />
+			<span className="sr-only">Remove</span>
+		</ComboboxPrimitive.ComboboxTagGroupItemRemove>
+	</ComboboxPrimitive.ComboboxTagGroupItem>
+))
+ComboboxTag.displayName = "ComboboxTag"
+
+export const ComboboxContent = React.forwardRef<
+	React.ElementRef<typeof ComboboxPrimitive.Content>,
+	React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Content>
+>(({ className, align = "start", alignOffset = 0, ...props }, ref) => (
+	<ComboboxPrimitive.Portal>
+		<ComboboxPrimitive.Content
+			ref={ref}
+			align={align}
+			alignOffset={alignOffset}
+			className={cn(
+				"min-w-72 border-vscode-dropdown-border relative z-50 left-0 max-h-96 w-[--radix-popover-trigger-width] overflow-y-auto overflow-x-hidden rounded-xs border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+				className,
+			)}
+			{...props}
+		/>
+	</ComboboxPrimitive.Portal>
+))
+ComboboxContent.displayName = "ComboboxContent"
+
+export const ComboboxEmpty = React.forwardRef<
+	React.ElementRef<typeof ComboboxPrimitive.Empty>,
+	React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Empty>
+>(({ className, ...props }, ref) => (
+	<ComboboxPrimitive.Empty ref={ref} className={cn("py-6 text-center text-sm", className)} {...props} />
+))
+ComboboxEmpty.displayName = "ComboboxEmpty"
+
+export const ComboboxLoading = React.forwardRef<
+	React.ElementRef<typeof ComboboxPrimitive.Loading>,
+	React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Loading>
+>(({ className, ...props }, ref) => (
+	<ComboboxPrimitive.Loading
+		ref={ref}
+		className={cn("flex items-center justify-center px-1.5 py-2", className)}
+		{...props}>
+		<Loader className="size-4 animate-spin [mask:conic-gradient(transparent_45deg,_white)]" />
+	</ComboboxPrimitive.Loading>
+))
+ComboboxLoading.displayName = "ComboboxLoading"
+
+export const ComboboxGroup = React.forwardRef<
+	React.ElementRef<typeof ComboboxPrimitive.Group>,
+	React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Group>
+>(({ className, ...props }, ref) => (
+	<ComboboxPrimitive.Group
+		ref={ref}
+		className={cn(
+			"[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-sm [&_[cmdk-group-heading]]:font-semibold",
+			className,
+		)}
+		{...props}
+	/>
+))
+ComboboxGroup.displayName = "ComboboxGroup"
+
+const ComboboxSeparator = React.forwardRef<
+	React.ElementRef<typeof ComboboxPrimitive.Separator>,
+	React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Separator>
+>(({ className, ...props }, ref) => (
+	<ComboboxPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
+))
+ComboboxSeparator.displayName = "ComboboxSeparator"
+
+export const comboboxItemStyle = cva(
+	"relative flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-vscode-dropdown-foreground data-[disabled=true]:opacity-50",
+)
+
+export const ComboboxItem = React.forwardRef<
+	React.ElementRef<typeof ComboboxPrimitive.Item>,
+	Omit<React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Item>, "children"> &
+		Pick<React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.ItemText>, "children">
+>(({ className, children, ...props }, ref) => (
+	<ComboboxPrimitive.Item ref={ref} className={cn(comboboxItemStyle(), className)} {...props}>
+		<ComboboxPrimitive.ItemText>{children}</ComboboxPrimitive.ItemText>
+		<ComboboxPrimitive.ItemIndicator className="absolute right-2 flex size-3.5 items-center justify-center">
+			<Check className="size-4" />
+		</ComboboxPrimitive.ItemIndicator>
+	</ComboboxPrimitive.Item>
+))
+ComboboxItem.displayName = "ComboboxItem"

+ 157 - 0
webview-ui/src/components/ui/input-base.tsx

@@ -0,0 +1,157 @@
+/* eslint-disable react/jsx-no-comment-textnodes */
+/* eslint-disable react/jsx-pascal-case */
+"use client"
+
+import * as React from "react"
+import { composeEventHandlers } from "@radix-ui/primitive"
+import { composeRefs } from "@radix-ui/react-compose-refs"
+import { Primitive } from "@radix-ui/react-primitive"
+import { Slot } from "@radix-ui/react-slot"
+
+import { cn } from "@/lib/utils"
+import { Button } from "./button"
+
+export type InputBaseContextProps = Pick<InputBaseProps, "autoFocus" | "disabled"> & {
+	controlRef: React.RefObject<HTMLElement>
+	onFocusedChange: (focused: boolean) => void
+}
+
+const InputBaseContext = React.createContext<InputBaseContextProps>({
+	autoFocus: false,
+	controlRef: { current: null },
+	disabled: false,
+	onFocusedChange: () => {},
+})
+
+const useInputBaseContext = () => React.useContext(InputBaseContext)
+
+export interface InputBaseProps extends React.ComponentPropsWithoutRef<typeof Primitive.div> {
+	autoFocus?: boolean
+	disabled?: boolean
+}
+
+export const InputBase = React.forwardRef<React.ElementRef<typeof Primitive.div>, InputBaseProps>(
+	({ autoFocus, disabled, className, onClick, ...props }, ref) => {
+		// eslint-disable-next-line @typescript-eslint/no-unused-vars
+		const [focused, setFocused] = React.useState(false)
+
+		const controlRef = React.useRef<HTMLElement>(null)
+
+		return (
+			<InputBaseContext.Provider
+				value={{
+					autoFocus,
+					controlRef,
+					disabled,
+					onFocusedChange: setFocused,
+				}}>
+				<Primitive.div
+					ref={ref}
+					onClick={composeEventHandlers(onClick, (event) => {
+						// Based on MUI's <InputBase /> implementation.
+						// https://github.com/mui/material-ui/blob/master/packages/mui-material/src/InputBase/InputBase.js#L458~L460
+						if (controlRef.current && event.currentTarget === event.target) {
+							controlRef.current.focus()
+						}
+					})}
+					className={cn(
+						"flex w-full text-vscode-input-foreground border border-vscode-dropdown-border  bg-vscode-input-background rounded-xs px-3 py-0.5 text-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus:outline-0 focus-visible:outline-none focus-visible:border-vscode-focusBorder disabled:cursor-not-allowed disabled:opacity-50",
+						disabled && "cursor-not-allowed opacity-50",
+						className,
+					)}
+					{...props}
+				/>
+			</InputBaseContext.Provider>
+		)
+	},
+)
+InputBase.displayName = "InputBase"
+
+export const InputBaseFlexWrapper = React.forwardRef<
+	React.ElementRef<typeof Primitive.div>,
+	React.ComponentPropsWithoutRef<typeof Primitive.div>
+>(({ className, ...props }, ref) => (
+	<Primitive.div ref={ref} className={cn("flex flex-1 flex-wrap", className)} {...props} />
+))
+InputBaseFlexWrapper.displayName = "InputBaseFlexWrapper"
+
+export const InputBaseControl = React.forwardRef<
+	React.ElementRef<typeof Slot>,
+	React.ComponentPropsWithoutRef<typeof Slot>
+>(({ onFocus, onBlur, ...props }, ref) => {
+	const { controlRef, autoFocus, disabled, onFocusedChange } = useInputBaseContext()
+
+	return (
+		<Slot
+			ref={composeRefs(controlRef, ref)}
+			autoFocus={autoFocus}
+			onFocus={composeEventHandlers(onFocus, () => onFocusedChange(true))}
+			onBlur={composeEventHandlers(onBlur, () => onFocusedChange(false))}
+			{...{ disabled }}
+			{...props}
+		/>
+	)
+})
+InputBaseControl.displayName = "InputBaseControl"
+
+export interface InputBaseAdornmentProps extends React.ComponentPropsWithoutRef<"div"> {
+	asChild?: boolean
+	disablePointerEvents?: boolean
+}
+
+export const InputBaseAdornment = React.forwardRef<React.ElementRef<"div">, InputBaseAdornmentProps>(
+	({ className, disablePointerEvents, asChild, children, ...props }, ref) => {
+		const Comp = asChild ? Slot : typeof children === "string" ? "p" : "div"
+
+		const isAction = React.isValidElement(children) && children.type === InputBaseAdornmentButton
+
+		return (
+			<Comp
+				ref={ref}
+				className={cn(
+					"flex items-center text-muted-foreground [&_svg]:size-4",
+					(!isAction || disablePointerEvents) && "pointer-events-none",
+					className,
+				)}
+				{...props}>
+				{children}
+			</Comp>
+		)
+	},
+)
+InputBaseAdornment.displayName = "InputBaseAdornment"
+
+export const InputBaseAdornmentButton = React.forwardRef<
+	React.ElementRef<typeof Button>,
+	React.ComponentPropsWithoutRef<typeof Button>
+>(({ type = "button", variant = "ghost", size = "icon", disabled: disabledProp, className, ...props }, ref) => {
+	const { disabled } = useInputBaseContext()
+
+	return (
+		<Button
+			ref={ref}
+			type={type}
+			variant={variant}
+			size={size}
+			disabled={disabled || disabledProp}
+			className={cn("size-6", className)}
+			{...props}
+		/>
+	)
+})
+InputBaseAdornmentButton.displayName = "InputBaseAdornmentButton"
+
+export const InputBaseInput = React.forwardRef<
+	React.ElementRef<typeof Primitive.input>,
+	React.ComponentPropsWithoutRef<typeof Primitive.input>
+>(({ className, ...props }, ref) => (
+	<Primitive.input
+		ref={ref}
+		className={cn(
+			"w-full flex-1 bg-transparent file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus:outline-none disabled:pointer-events-none",
+			className,
+		)}
+		{...props}
+	/>
+))
+InputBaseInput.displayName = "InputBaseInput"

+ 2 - 0
webview-ui/src/components/welcome/WelcomeView.tsx

@@ -42,6 +42,8 @@ const WelcomeView = () => {
 					apiConfiguration={apiConfiguration || {}}
 					uriScheme={uriScheme}
 					setApiConfigurationField={(field, value) => setApiConfiguration({ [field]: value })}
+					errorMessage={errorMessage}
+					setErrorMessage={setErrorMessage}
 				/>
 			</div>
 

+ 1 - 83
webview-ui/src/context/ExtensionStateContext.tsx

@@ -1,18 +1,7 @@
 import React, { createContext, useCallback, useContext, useEffect, useState } from "react"
 import { useEvent } from "react-use"
 import { ApiConfigMeta, ExtensionMessage, ExtensionState } from "../../../src/shared/ExtensionMessage"
-import {
-	ApiConfiguration,
-	ModelInfo,
-	glamaDefaultModelId,
-	glamaDefaultModelInfo,
-	openRouterDefaultModelId,
-	openRouterDefaultModelInfo,
-	unboundDefaultModelId,
-	unboundDefaultModelInfo,
-	requestyDefaultModelId,
-	requestyDefaultModelInfo,
-} from "../../../src/shared/api"
+import { ApiConfiguration } from "../../../src/shared/api"
 import { vscode } from "../utils/vscode"
 import { convertTextMateToHljs } from "../utils/textMateToHljs"
 import { findLastIndex } from "../../../src/shared/array"
@@ -26,11 +15,6 @@ export interface ExtensionStateContextType extends ExtensionState {
 	didHydrateState: boolean
 	showWelcome: boolean
 	theme: any
-	glamaModels: Record<string, ModelInfo>
-	requestyModels: Record<string, ModelInfo>
-	openRouterModels: Record<string, ModelInfo>
-	unboundModels: Record<string, ModelInfo>
-	openAiModels: string[]
 	mcpServers: McpServer[]
 	currentCheckpoint?: string
 	filePaths: string[]
@@ -70,7 +54,6 @@ export interface ExtensionStateContextType extends ExtensionState {
 	setRateLimitSeconds: (value: number) => void
 	setCurrentApiConfigName: (value: string) => void
 	setListApiConfigMeta: (value: ApiConfigMeta[]) => void
-	onUpdateApiConfig: (apiConfig: ApiConfiguration) => void
 	mode: Mode
 	setMode: (value: Mode) => void
 	setCustomModePrompts: (value: CustomModePrompts) => void
@@ -125,21 +108,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 	const [showWelcome, setShowWelcome] = useState(false)
 	const [theme, setTheme] = useState<any>(undefined)
 	const [filePaths, setFilePaths] = useState<string[]>([])
-	const [glamaModels, setGlamaModels] = useState<Record<string, ModelInfo>>({
-		[glamaDefaultModelId]: glamaDefaultModelInfo,
-	})
 	const [openedTabs, setOpenedTabs] = useState<Array<{ label: string; isActive: boolean; path?: string }>>([])
-	const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
-		[openRouterDefaultModelId]: openRouterDefaultModelInfo,
-	})
-	const [unboundModels, setUnboundModels] = useState<Record<string, ModelInfo>>({
-		[unboundDefaultModelId]: unboundDefaultModelInfo,
-	})
-	const [requestyModels, setRequestyModels] = useState<Record<string, ModelInfo>>({
-		[requestyDefaultModelId]: requestyDefaultModelInfo,
-	})
 
-	const [openAiModels, setOpenAiModels] = useState<string[]>([])
 	const [mcpServers, setMcpServers] = useState<McpServer[]>([])
 	const [currentCheckpoint, setCurrentCheckpoint] = useState<string>()
 
@@ -147,18 +117,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		(value: ApiConfigMeta[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })),
 		[],
 	)
-
-	const onUpdateApiConfig = useCallback((apiConfig: ApiConfiguration) => {
-		setState((currentState) => {
-			vscode.postMessage({
-				type: "upsertApiConfiguration",
-				text: currentState.currentApiConfigName,
-				apiConfiguration: { ...currentState.apiConfiguration, ...apiConfig },
-			})
-			return currentState // No state update needed
-		})
-	}, [])
-
 	const handleMessage = useCallback(
 		(event: MessageEvent) => {
 			const message: ExtensionMessage = event.data
@@ -203,40 +161,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 					})
 					break
 				}
-				case "glamaModels": {
-					const updatedModels = message.glamaModels ?? {}
-					setGlamaModels({
-						[glamaDefaultModelId]: glamaDefaultModelInfo, // in case the extension sent a model list without the default model
-						...updatedModels,
-					})
-					break
-				}
-				case "openRouterModels": {
-					const updatedModels = message.openRouterModels ?? {}
-					setOpenRouterModels({
-						[openRouterDefaultModelId]: openRouterDefaultModelInfo, // in case the extension sent a model list without the default model
-						...updatedModels,
-					})
-					break
-				}
-				case "openAiModels": {
-					const updatedModels = message.openAiModels ?? []
-					setOpenAiModels(updatedModels)
-					break
-				}
-				case "unboundModels": {
-					const updatedModels = message.unboundModels ?? {}
-					setUnboundModels(updatedModels)
-					break
-				}
-				case "requestyModels": {
-					const updatedModels = message.requestyModels ?? {}
-					setRequestyModels({
-						[requestyDefaultModelId]: requestyDefaultModelInfo, // in case the extension sent a model list without the default model
-						...updatedModels,
-					})
-					break
-				}
 				case "mcpServers": {
 					setMcpServers(message.mcpServers ?? [])
 					break
@@ -265,11 +189,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		didHydrateState,
 		showWelcome,
 		theme,
-		glamaModels,
-		requestyModels,
-		openRouterModels,
-		openAiModels,
-		unboundModels,
 		mcpServers,
 		currentCheckpoint,
 		filePaths,
@@ -317,7 +236,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		setRateLimitSeconds: (value) => setState((prevState) => ({ ...prevState, rateLimitSeconds: value })),
 		setCurrentApiConfigName: (value) => setState((prevState) => ({ ...prevState, currentApiConfigName: value })),
 		setListApiConfigMeta,
-		onUpdateApiConfig,
 		setMode: (value: Mode) => setState((prevState) => ({ ...prevState, mode: value })),
 		setCustomModePrompts: (value) => setState((prevState) => ({ ...prevState, customModePrompts: value })),
 		setCustomSupportPrompts: (value) => setState((prevState) => ({ ...prevState, customSupportPrompts: value })),

+ 4 - 9
webview-ui/src/utils/validate.ts

@@ -1,9 +1,4 @@
-import {
-	ApiConfiguration,
-	glamaDefaultModelId,
-	openRouterDefaultModelId,
-	unboundDefaultModelId,
-} from "../../../src/shared/api"
+import { ApiConfiguration } from "../../../src/shared/api"
 import { ModelInfo } from "../../../src/shared/api"
 export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): string | undefined {
 	if (apiConfiguration) {
@@ -86,7 +81,7 @@ export function validateModelId(
 	if (apiConfiguration) {
 		switch (apiConfiguration.apiProvider) {
 			case "glama":
-				const glamaModelId = apiConfiguration.glamaModelId || glamaDefaultModelId // in case the user hasn't changed the model id, it will be undefined by default
+				const glamaModelId = apiConfiguration.glamaModelId
 				if (!glamaModelId) {
 					return "You must provide a model ID."
 				}
@@ -96,7 +91,7 @@ export function validateModelId(
 				}
 				break
 			case "openrouter":
-				const modelId = apiConfiguration.openRouterModelId || openRouterDefaultModelId // in case the user hasn't changed the model id, it will be undefined by default
+				const modelId = apiConfiguration.openRouterModelId
 				if (!modelId) {
 					return "You must provide a model ID."
 				}
@@ -106,7 +101,7 @@ export function validateModelId(
 				}
 				break
 			case "unbound":
-				const unboundModelId = apiConfiguration.unboundModelId || unboundDefaultModelId
+				const unboundModelId = apiConfiguration.unboundModelId
 				if (!unboundModelId) {
 					return "You must provide a model ID."
 				}