Browse Source

Better model picker

Roo Code 10 months ago
parent
commit
0bc9e17c1a

+ 1 - 1
webview-ui/jest.config.cjs

@@ -6,7 +6,7 @@ module.exports = {
 	moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
 	transform: { "^.+\\.(ts|tsx)$": ["ts-jest", { tsconfig: { jsx: "react-jsx" } }] },
 	testMatch: ["<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}", "<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}"],
-	setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts", "@testing-library/jest-dom/extend-expect"],
+	setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
 	moduleNameMapper: {
 		"\\.(css|less|scss|sass)$": "identity-obj-proxy",
 		"^vscrui$": "<rootDir>/src/__mocks__/vscrui.ts",

+ 38 - 142
webview-ui/package-lock.json

@@ -42,9 +42,9 @@
 				"@storybook/react": "^8.5.2",
 				"@storybook/react-vite": "^8.5.2",
 				"@storybook/test": "^8.5.2",
-				"@testing-library/jest-dom": "^5.17.0",
-				"@testing-library/react": "^13.4.0",
-				"@testing-library/user-event": "^13.5.0",
+				"@testing-library/jest-dom": "^6.6.3",
+				"@testing-library/react": "^16.2.0",
+				"@testing-library/user-event": "^14.6.1",
 				"@types/jest": "^27.5.2",
 				"@types/node": "^18.0.0",
 				"@types/react": "^18.3.18",
@@ -5498,24 +5498,22 @@
 			}
 		},
 		"node_modules/@testing-library/jest-dom": {
-			"version": "5.17.0",
-			"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz",
-			"integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==",
+			"version": "6.6.3",
+			"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz",
+			"integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==",
 			"dev": true,
 			"license": "MIT",
 			"dependencies": {
-				"@adobe/css-tools": "^4.0.1",
-				"@babel/runtime": "^7.9.2",
-				"@types/testing-library__jest-dom": "^5.9.1",
+				"@adobe/css-tools": "^4.4.0",
 				"aria-query": "^5.0.0",
 				"chalk": "^3.0.0",
 				"css.escape": "^1.5.1",
-				"dom-accessibility-api": "^0.5.6",
-				"lodash": "^4.17.15",
+				"dom-accessibility-api": "^0.6.3",
+				"lodash": "^4.17.21",
 				"redent": "^3.0.0"
 			},
 			"engines": {
-				"node": ">=8",
+				"node": ">=14",
 				"npm": ">=6",
 				"yarn": ">=1"
 			}
@@ -5534,66 +5532,49 @@
 				"node": ">=8"
 			}
 		},
+		"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+			"version": "0.6.3",
+			"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+			"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+			"dev": true,
+			"license": "MIT"
+		},
 		"node_modules/@testing-library/react": {
-			"version": "13.4.0",
-			"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.4.0.tgz",
-			"integrity": "sha512-sXOGON+WNTh3MLE9rve97ftaZukN3oNf2KjDy7YTx6hcTO2uuLHuCGynMDhFwGw/jYf4OJ2Qk0i4i79qMNNkyw==",
+			"version": "16.2.0",
+			"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.2.0.tgz",
+			"integrity": "sha512-2cSskAvA1QNtKc8Y9VJQRv0tm3hLVgxRGDB+KYhIaPQJ1I+RHbhIXcM+zClKXzMes/wshsMVzf4B9vS4IZpqDQ==",
 			"dev": true,
 			"license": "MIT",
 			"dependencies": {
-				"@babel/runtime": "^7.12.5",
-				"@testing-library/dom": "^8.5.0",
-				"@types/react-dom": "^18.0.0"
+				"@babel/runtime": "^7.12.5"
 			},
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			},
 			"peerDependencies": {
-				"react": "^18.0.0",
-				"react-dom": "^18.0.0"
-			}
-		},
-		"node_modules/@testing-library/react/node_modules/@testing-library/dom": {
-			"version": "8.20.1",
-			"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz",
-			"integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==",
-			"dev": true,
-			"license": "MIT",
-			"dependencies": {
-				"@babel/code-frame": "^7.10.4",
-				"@babel/runtime": "^7.12.5",
-				"@types/aria-query": "^5.0.1",
-				"aria-query": "5.1.3",
-				"chalk": "^4.1.0",
-				"dom-accessibility-api": "^0.5.9",
-				"lz-string": "^1.5.0",
-				"pretty-format": "^27.0.2"
+				"@testing-library/dom": "^10.0.0",
+				"@types/react": "^18.0.0 || ^19.0.0",
+				"@types/react-dom": "^18.0.0 || ^19.0.0",
+				"react": "^18.0.0 || ^19.0.0",
+				"react-dom": "^18.0.0 || ^19.0.0"
 			},
-			"engines": {
-				"node": ">=12"
-			}
-		},
-		"node_modules/@testing-library/react/node_modules/aria-query": {
-			"version": "5.1.3",
-			"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz",
-			"integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==",
-			"dev": true,
-			"license": "Apache-2.0",
-			"dependencies": {
-				"deep-equal": "^2.0.5"
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				},
+				"@types/react-dom": {
+					"optional": true
+				}
 			}
 		},
 		"node_modules/@testing-library/user-event": {
-			"version": "13.5.0",
-			"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz",
-			"integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==",
+			"version": "14.6.1",
+			"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+			"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
 			"dev": true,
 			"license": "MIT",
-			"dependencies": {
-				"@babel/runtime": "^7.12.5"
-			},
 			"engines": {
-				"node": ">=10",
+				"node": ">=12",
 				"npm": ">=6"
 			},
 			"peerDependencies": {
@@ -8255,39 +8236,6 @@
 				"node": ">=6"
 			}
 		},
-		"node_modules/deep-equal": {
-			"version": "2.2.3",
-			"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
-			"integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==",
-			"dev": true,
-			"license": "MIT",
-			"dependencies": {
-				"array-buffer-byte-length": "^1.0.0",
-				"call-bind": "^1.0.5",
-				"es-get-iterator": "^1.1.3",
-				"get-intrinsic": "^1.2.2",
-				"is-arguments": "^1.1.1",
-				"is-array-buffer": "^3.0.2",
-				"is-date-object": "^1.0.5",
-				"is-regex": "^1.1.4",
-				"is-shared-array-buffer": "^1.0.2",
-				"isarray": "^2.0.5",
-				"object-is": "^1.1.5",
-				"object-keys": "^1.1.1",
-				"object.assign": "^4.1.4",
-				"regexp.prototype.flags": "^1.5.1",
-				"side-channel": "^1.0.4",
-				"which-boxed-primitive": "^1.0.2",
-				"which-collection": "^1.0.1",
-				"which-typed-array": "^1.1.13"
-			},
-			"engines": {
-				"node": ">= 0.4"
-			},
-			"funding": {
-				"url": "https://github.com/sponsors/ljharb"
-			}
-		},
 		"node_modules/deep-is": {
 			"version": "0.1.4",
 			"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -8638,27 +8586,6 @@
 				"node": ">= 0.4"
 			}
 		},
-		"node_modules/es-get-iterator": {
-			"version": "1.1.3",
-			"resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz",
-			"integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==",
-			"dev": true,
-			"license": "MIT",
-			"dependencies": {
-				"call-bind": "^1.0.2",
-				"get-intrinsic": "^1.1.3",
-				"has-symbols": "^1.0.3",
-				"is-arguments": "^1.1.1",
-				"is-map": "^2.0.2",
-				"is-set": "^2.0.2",
-				"is-string": "^1.0.7",
-				"isarray": "^2.0.5",
-				"stop-iteration-iterator": "^1.0.0"
-			},
-			"funding": {
-				"url": "https://github.com/sponsors/ljharb"
-			}
-		},
 		"node_modules/es-iterator-helpers": {
 			"version": "1.2.1",
 			"resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz",
@@ -13201,23 +13128,6 @@
 				"url": "https://github.com/sponsors/ljharb"
 			}
 		},
-		"node_modules/object-is": {
-			"version": "1.1.6",
-			"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
-			"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
-			"dev": true,
-			"license": "MIT",
-			"dependencies": {
-				"call-bind": "^1.0.7",
-				"define-properties": "^1.2.1"
-			},
-			"engines": {
-				"node": ">= 0.4"
-			},
-			"funding": {
-				"url": "https://github.com/sponsors/ljharb"
-			}
-		},
 		"node_modules/object-keys": {
 			"version": "1.1.1",
 			"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
@@ -14923,20 +14833,6 @@
 				"stacktrace-gps": "^3.0.4"
 			}
 		},
-		"node_modules/stop-iteration-iterator": {
-			"version": "1.1.0",
-			"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
-			"integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
-			"dev": true,
-			"license": "MIT",
-			"dependencies": {
-				"es-errors": "^1.3.0",
-				"internal-slot": "^1.1.0"
-			},
-			"engines": {
-				"node": ">= 0.4"
-			}
-		},
 		"node_modules/storybook": {
 			"version": "8.5.2",
 			"resolved": "https://registry.npmjs.org/storybook/-/storybook-8.5.2.tgz",

+ 3 - 3
webview-ui/package.json

@@ -48,9 +48,9 @@
 		"@storybook/react": "^8.5.2",
 		"@storybook/react-vite": "^8.5.2",
 		"@storybook/test": "^8.5.2",
-		"@testing-library/jest-dom": "^5.17.0",
-		"@testing-library/react": "^13.4.0",
-		"@testing-library/user-event": "^13.5.0",
+		"@testing-library/jest-dom": "^6.6.3",
+		"@testing-library/react": "^16.2.0",
+		"@testing-library/user-event": "^14.6.1",
 		"@types/jest": "^27.5.2",
 		"@types/node": "^18.0.0",
 		"@types/react": "^18.3.18",

+ 42 - 26
webview-ui/src/components/history/__tests__/HistoryView.test.tsx

@@ -1,13 +1,13 @@
-import React from "react"
-import { render, screen, fireEvent, within, waitFor } from "@testing-library/react"
-import userEvent from "@testing-library/user-event"
+// cd webview-ui && npx jest src/components/history/__tests__/HistoryView.test.ts
+
+import { render, screen, fireEvent, within, act } from "@testing-library/react"
 import HistoryView from "../HistoryView"
 import { useExtensionState } from "../../../context/ExtensionStateContext"
 import { vscode } from "../../../utils/vscode"
 
-// Mock dependencies
 jest.mock("../../../context/ExtensionStateContext")
 jest.mock("../../../utils/vscode")
+
 jest.mock("react-virtuoso", () => ({
 	Virtuoso: ({ data, itemContent }: any) => (
 		<div data-testid="virtuoso-container">
@@ -41,21 +41,21 @@ const mockTaskHistory = [
 ]
 
 describe("HistoryView", () => {
-	beforeEach(() => {
-		// Reset all mocks before each test
-		jest.clearAllMocks()
+	beforeAll(() => {
 		jest.useFakeTimers()
+	})
 
-		// Mock useExtensionState implementation
+	afterAll(() => {
+		jest.useRealTimers()
+	})
+
+	beforeEach(() => {
+		jest.clearAllMocks()
 		;(useExtensionState as jest.Mock).mockReturnValue({
 			taskHistory: mockTaskHistory,
 		})
 	})
 
-	afterEach(() => {
-		jest.useRealTimers()
-	})
-
 	it("renders history items correctly", () => {
 		const onDone = jest.fn()
 		render(<HistoryView onDone={onDone} />)
@@ -67,7 +67,7 @@ describe("HistoryView", () => {
 		expect(screen.getByText("Test task 2")).toBeInTheDocument()
 	})
 
-	it("handles search functionality", async () => {
+	it("handles search functionality", () => {
 		const onDone = jest.fn()
 		render(<HistoryView onDone={onDone} />)
 
@@ -76,17 +76,23 @@ describe("HistoryView", () => {
 		const radioGroup = screen.getByRole("radiogroup")
 
 		// Type in search
-		await userEvent.type(searchInput, "task 1")
+		fireEvent.input(searchInput, { target: { value: "task 1" } })
+
+		// Advance timers to process search state update
+		jest.advanceTimersByTime(100)
 
 		// Check if sort option automatically changes to "Most Relevant"
 		const mostRelevantRadio = within(radioGroup).getByLabelText("Most Relevant")
 		expect(mostRelevantRadio).not.toBeDisabled()
 
-		// Click and wait for radio update
+		// Click the radio button
 		fireEvent.click(mostRelevantRadio)
 
-		// Wait for radio button to be checked
-		const updatedRadio = await within(radioGroup).findByRole("radio", { name: "Most Relevant", checked: true })
+		// Advance timers to process radio button state update
+		jest.advanceTimersByTime(100)
+
+		// Verify radio button is checked
+		const updatedRadio = within(radioGroup).getByRole("radio", { name: "Most Relevant", checked: true })
 		expect(updatedRadio).toBeInTheDocument()
 	})
 
@@ -148,6 +154,7 @@ describe("HistoryView", () => {
 	})
 
 	it("handles task copying", async () => {
+		// Setup clipboard mock that resolves immediately
 		const mockClipboard = {
 			writeText: jest.fn().mockResolvedValue(undefined),
 		}
@@ -161,20 +168,29 @@ describe("HistoryView", () => {
 		fireEvent.mouseEnter(taskContainer)
 
 		const copyButton = within(taskContainer).getByTitle("Copy Prompt")
-		await userEvent.click(copyButton)
 
-		// Verify clipboard API was called
+		// Click the copy button and wait for clipboard operation
+		await act(async () => {
+			fireEvent.click(copyButton)
+			// Let the clipboard Promise resolve
+			await Promise.resolve()
+			// Let React process the first state update
+			await Promise.resolve()
+		})
+
+		// Verify clipboard was called
 		expect(navigator.clipboard.writeText).toHaveBeenCalledWith("Test task 1")
 
-		// Wait for copy modal to appear
-		const copyModal = await screen.findByText("Prompt Copied to Clipboard")
-		expect(copyModal).toBeInTheDocument()
+		// Verify modal appears immediately after clipboard operation
+		expect(screen.getByText("Prompt Copied to Clipboard")).toBeInTheDocument()
 
-		// Fast-forward timers and wait for modal to disappear
-		jest.advanceTimersByTime(2000)
-		await waitFor(() => {
-			expect(screen.queryByText("Prompt Copied to Clipboard")).not.toBeInTheDocument()
+		// Advance timer to trigger the setTimeout for modal disappearance
+		act(() => {
+			jest.advanceTimersByTime(2000)
 		})
+
+		// Verify modal is gone
+		expect(screen.queryByText("Prompt Copied to Clipboard")).not.toBeInTheDocument()
 	})
 
 	it("formats dates correctly", () => {

+ 10 - 141
webview-ui/src/components/settings/ApiOptions.tsx

@@ -1,8 +1,9 @@
-import { Checkbox, Dropdown, Pane } from "vscrui"
-import type { DropdownOption } from "vscrui"
-import { VSCodeLink, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
-import { Fragment, memo, useCallback, useEffect, useMemo, useState } from "react"
+import { memo, useCallback, useEffect, useMemo, useState } from "react"
 import { useEvent, useInterval } from "react-use"
+import { Checkbox, Dropdown, Pane, type DropdownOption } from "vscrui"
+import { VSCodeLink, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
+import * as vscodemodels from "vscode"
+
 import {
 	ApiConfiguration,
 	ModelInfo,
@@ -32,14 +33,12 @@ import {
 import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
 import { useExtensionState } from "../../context/ExtensionStateContext"
 import { vscode } from "../../utils/vscode"
-import * as vscodemodels from "vscode"
 import VSCodeButtonLink from "../common/VSCodeButtonLink"
-import OpenRouterModelPicker, {
-	ModelDescriptionMarkdown,
-	OPENROUTER_MODEL_PICKER_Z_INDEX,
-} from "./OpenRouterModelPicker"
+import { OpenRouterModelPicker } from "./OpenRouterModelPicker"
 import OpenAiModelPicker from "./OpenAiModelPicker"
-import GlamaModelPicker from "./GlamaModelPicker"
+import { GlamaModelPicker } from "./GlamaModelPicker"
+import { ModelInfoView } from "./ModelInfoView"
+import { DROPDOWN_Z_INDEX } from "./styles"
 
 interface ApiOptionsProps {
 	apiErrorMessage?: string
@@ -137,7 +136,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
 							},
 						})
 					}}
-					style={{ minWidth: 130, position: "relative", zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX + 1 }}
+					style={{ minWidth: 130, position: "relative", zIndex: DROPDOWN_Z_INDEX + 1 }}
 					options={[
 						{ value: "openrouter", label: "OpenRouter" },
 						{ value: "anthropic", label: "Anthropic" },
@@ -1386,136 +1385,6 @@ export function getOpenRouterAuthUrl(uriScheme?: string) {
 	return `https://openrouter.ai/auth?callback_url=${uriScheme || "vscode"}://rooveterinaryinc.roo-cline/openrouter`
 }
 
-export const formatPrice = (price: number) => {
-	return new Intl.NumberFormat("en-US", {
-		style: "currency",
-		currency: "USD",
-		minimumFractionDigits: 2,
-		maximumFractionDigits: 2,
-	}).format(price)
-}
-
-export const ModelInfoView = ({
-	selectedModelId,
-	modelInfo,
-	isDescriptionExpanded,
-	setIsDescriptionExpanded,
-}: {
-	selectedModelId: string
-	modelInfo: ModelInfo
-	isDescriptionExpanded: boolean
-	setIsDescriptionExpanded: (isExpanded: boolean) => void
-}) => {
-	const isGemini = Object.keys(geminiModels).includes(selectedModelId)
-
-	const infoItems = [
-		modelInfo.description && (
-			<ModelDescriptionMarkdown
-				key="description"
-				markdown={modelInfo.description}
-				isExpanded={isDescriptionExpanded}
-				setIsExpanded={setIsDescriptionExpanded}
-			/>
-		),
-		<ModelInfoSupportsItem
-			key="supportsImages"
-			isSupported={modelInfo.supportsImages ?? false}
-			supportsLabel="Supports images"
-			doesNotSupportLabel="Does not support images"
-		/>,
-		<ModelInfoSupportsItem
-			key="supportsComputerUse"
-			isSupported={modelInfo.supportsComputerUse ?? false}
-			supportsLabel="Supports computer use"
-			doesNotSupportLabel="Does not support computer use"
-		/>,
-		!isGemini && (
-			<ModelInfoSupportsItem
-				key="supportsPromptCache"
-				isSupported={modelInfo.supportsPromptCache}
-				supportsLabel="Supports prompt caching"
-				doesNotSupportLabel="Does not support prompt caching"
-			/>
-		),
-		modelInfo.maxTokens !== undefined && modelInfo.maxTokens > 0 && (
-			<span key="maxTokens">
-				<span style={{ fontWeight: 500 }}>Max output:</span> {modelInfo.maxTokens?.toLocaleString()} tokens
-			</span>
-		),
-		modelInfo.inputPrice !== undefined && modelInfo.inputPrice > 0 && (
-			<span key="inputPrice">
-				<span style={{ fontWeight: 500 }}>Input price:</span> {formatPrice(modelInfo.inputPrice)}/million tokens
-			</span>
-		),
-		modelInfo.supportsPromptCache && modelInfo.cacheWritesPrice && (
-			<span key="cacheWritesPrice">
-				<span style={{ fontWeight: 500 }}>Cache writes price:</span>{" "}
-				{formatPrice(modelInfo.cacheWritesPrice || 0)}/million tokens
-			</span>
-		),
-		modelInfo.supportsPromptCache && modelInfo.cacheReadsPrice && (
-			<span key="cacheReadsPrice">
-				<span style={{ fontWeight: 500 }}>Cache reads price:</span>{" "}
-				{formatPrice(modelInfo.cacheReadsPrice || 0)}/million tokens
-			</span>
-		),
-		modelInfo.outputPrice !== undefined && modelInfo.outputPrice > 0 && (
-			<span key="outputPrice">
-				<span style={{ fontWeight: 500 }}>Output price:</span> {formatPrice(modelInfo.outputPrice)}/million
-				tokens
-			</span>
-		),
-		isGemini && (
-			<span key="geminiInfo" style={{ fontStyle: "italic" }}>
-				* Free up to {selectedModelId && selectedModelId.includes("flash") ? "15" : "2"} requests per minute.
-				After that, billing depends on prompt size.{" "}
-				<VSCodeLink href="https://ai.google.dev/pricing" style={{ display: "inline", fontSize: "inherit" }}>
-					For more info, see pricing details.
-				</VSCodeLink>
-			</span>
-		),
-	].filter(Boolean)
-
-	return (
-		<p style={{ fontSize: "12px", marginTop: "2px", color: "var(--vscode-descriptionForeground)" }}>
-			{infoItems.map((item, index) => (
-				<Fragment key={index}>
-					{item}
-					{index < infoItems.length - 1 && <br />}
-				</Fragment>
-			))}
-		</p>
-	)
-}
-
-const ModelInfoSupportsItem = ({
-	isSupported,
-	supportsLabel,
-	doesNotSupportLabel,
-}: {
-	isSupported: boolean
-	supportsLabel: string
-	doesNotSupportLabel: string
-}) => (
-	<span
-		style={{
-			fontWeight: 500,
-			color: isSupported ? "var(--vscode-charts-green)" : "var(--vscode-errorForeground)",
-		}}>
-		<i
-			className={`codicon codicon-${isSupported ? "check" : "x"}`}
-			style={{
-				marginRight: 4,
-				marginBottom: isSupported ? 1 : -1,
-				fontSize: isSupported ? 11 : 13,
-				fontWeight: 700,
-				display: "inline-block",
-				verticalAlign: "bottom",
-			}}></i>
-		{isSupported ? supportsLabel : doesNotSupportLabel}
-	</span>
-)
-
 export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
 	const provider = apiConfiguration?.apiProvider || "anthropic"
 	const modelId = apiConfiguration?.apiModelId

+ 12 - 412
webview-ui/src/components/settings/GlamaModelPicker.tsx

@@ -1,415 +1,15 @@
-import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
-import debounce from "debounce"
-import { Fzf } from "fzf"
-import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react"
-import { useRemark } from "react-remark"
-import { useMount } from "react-use"
-import styled from "styled-components"
+import { ModelPicker } from "./ModelPicker"
 import { glamaDefaultModelId } from "../../../../src/shared/api"
-import { useExtensionState } from "../../context/ExtensionStateContext"
-import { vscode } from "../../utils/vscode"
-import { highlightFzfMatch } from "../../utils/highlight"
-import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
 
-const GlamaModelPicker: React.FC = () => {
-	const { apiConfiguration, setApiConfiguration, glamaModels, onUpdateApiConfig } = useExtensionState()
-	const [searchTerm, setSearchTerm] = useState(apiConfiguration?.glamaModelId || glamaDefaultModelId)
-	const [isDropdownVisible, setIsDropdownVisible] = useState(false)
-	const [selectedIndex, setSelectedIndex] = useState(-1)
-	const dropdownRef = useRef<HTMLDivElement>(null)
-	const itemRefs = useRef<(HTMLDivElement | null)[]>([])
-	const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
-	const dropdownListRef = useRef<HTMLDivElement>(null)
-
-	const handleModelChange = (newModelId: string) => {
-		// could be setting invalid model id/undefined info but validation will catch it
-		const apiConfig = {
-			...apiConfiguration,
-			glamaModelId: newModelId,
-			glamaModelInfo: glamaModels[newModelId],
-		}
-		setApiConfiguration(apiConfig)
-		onUpdateApiConfig(apiConfig)
-
-		setSearchTerm(newModelId)
-	}
-
-	const { selectedModelId, selectedModelInfo } = useMemo(() => {
-		return normalizeApiConfiguration(apiConfiguration)
-	}, [apiConfiguration])
-
-	useEffect(() => {
-		if (apiConfiguration?.glamaModelId && apiConfiguration?.glamaModelId !== searchTerm) {
-			setSearchTerm(apiConfiguration?.glamaModelId)
-		}
-	}, [apiConfiguration, searchTerm])
-
-	const debouncedRefreshModels = useMemo(
-		() =>
-			debounce(() => {
-				vscode.postMessage({ type: "refreshGlamaModels" })
-			}, 50),
-		[],
-	)
-
-	useMount(() => {
-		debouncedRefreshModels()
-
-		// Cleanup debounced function
-		return () => {
-			debouncedRefreshModels.clear()
-		}
-	})
-
-	useEffect(() => {
-		const handleClickOutside = (event: MouseEvent) => {
-			if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
-				setIsDropdownVisible(false)
-			}
-		}
-
-		document.addEventListener("mousedown", handleClickOutside)
-		return () => {
-			document.removeEventListener("mousedown", handleClickOutside)
-		}
-	}, [])
-
-	const modelIds = useMemo(() => {
-		return Object.keys(glamaModels).sort((a, b) => a.localeCompare(b))
-	}, [glamaModels])
-
-	const searchableItems = useMemo(() => {
-		return modelIds.map((id) => ({
-			id,
-			html: id,
-		}))
-	}, [modelIds])
-
-	const fzf = useMemo(() => {
-		return new Fzf(searchableItems, {
-			selector: (item) => item.html,
-		})
-	}, [searchableItems])
-
-	const modelSearchResults = useMemo(() => {
-		if (!searchTerm) return searchableItems
-
-		const searchResults = fzf.find(searchTerm)
-		return searchResults.map((result) => ({
-			...result.item,
-			html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight"),
-		}))
-	}, [searchableItems, searchTerm, fzf])
-
-	const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
-		if (!isDropdownVisible) return
-
-		switch (event.key) {
-			case "ArrowDown":
-				event.preventDefault()
-				setSelectedIndex((prev) => (prev < modelSearchResults.length - 1 ? prev + 1 : prev))
-				break
-			case "ArrowUp":
-				event.preventDefault()
-				setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev))
-				break
-			case "Enter":
-				event.preventDefault()
-				if (selectedIndex >= 0 && selectedIndex < modelSearchResults.length) {
-					handleModelChange(modelSearchResults[selectedIndex].id)
-					setIsDropdownVisible(false)
-				}
-				break
-			case "Escape":
-				setIsDropdownVisible(false)
-				setSelectedIndex(-1)
-				break
-		}
-	}
-
-	const hasInfo = useMemo(() => {
-		return modelIds.some((id) => id.toLowerCase() === searchTerm.toLowerCase())
-	}, [modelIds, searchTerm])
-
-	useEffect(() => {
-		setSelectedIndex(-1)
-		if (dropdownListRef.current) {
-			dropdownListRef.current.scrollTop = 0
-		}
-	}, [searchTerm])
-
-	useEffect(() => {
-		if (selectedIndex >= 0 && itemRefs.current[selectedIndex]) {
-			itemRefs.current[selectedIndex]?.scrollIntoView({
-				block: "nearest",
-				behavior: "smooth",
-			})
-		}
-	}, [selectedIndex])
-
-	return (
-		<>
-			<style>
-				{`
-				.model-item-highlight {
-					background-color: var(--vscode-editor-findMatchHighlightBackground);
-					color: inherit;
-				}
-				`}
-			</style>
-			<div>
-				<label htmlFor="model-search">
-					<span style={{ fontWeight: 500 }}>Model</span>
-				</label>
-				<DropdownWrapper ref={dropdownRef}>
-					<VSCodeTextField
-						id="model-search"
-						placeholder="Search and select a model..."
-						value={searchTerm}
-						onInput={(e) => {
-							handleModelChange((e.target as HTMLInputElement)?.value?.toLowerCase())
-							setIsDropdownVisible(true)
-						}}
-						onFocus={() => setIsDropdownVisible(true)}
-						onKeyDown={handleKeyDown}
-						style={{ width: "100%", zIndex: GLAMA_MODEL_PICKER_Z_INDEX, position: "relative" }}>
-						{searchTerm && (
-							<div
-								className="input-icon-button codicon codicon-close"
-								aria-label="Clear search"
-								onClick={() => {
-									handleModelChange("")
-									setIsDropdownVisible(true)
-								}}
-								slot="end"
-								style={{
-									display: "flex",
-									justifyContent: "center",
-									alignItems: "center",
-									height: "100%",
-								}}
-							/>
-						)}
-					</VSCodeTextField>
-					{isDropdownVisible && (
-						<DropdownList ref={dropdownListRef}>
-							{modelSearchResults.map((item, index) => (
-								<DropdownItem
-									key={item.id}
-									ref={(el) => (itemRefs.current[index] = el)}
-									isSelected={index === selectedIndex}
-									onMouseEnter={() => setSelectedIndex(index)}
-									onClick={() => {
-										handleModelChange(item.id)
-										setIsDropdownVisible(false)
-									}}
-									dangerouslySetInnerHTML={{
-										__html: item.html,
-									}}
-								/>
-							))}
-						</DropdownList>
-					)}
-				</DropdownWrapper>
-			</div>
-
-			{hasInfo ? (
-				<ModelInfoView
-					selectedModelId={selectedModelId}
-					modelInfo={selectedModelInfo}
-					isDescriptionExpanded={isDescriptionExpanded}
-					setIsDescriptionExpanded={setIsDescriptionExpanded}
-				/>
-			) : (
-				<p
-					style={{
-						fontSize: "12px",
-						marginTop: 0,
-						color: "var(--vscode-descriptionForeground)",
-					}}>
-					The extension automatically fetches the latest list of models available on{" "}
-					<VSCodeLink style={{ display: "inline", fontSize: "inherit" }} href="https://glama.ai/models">
-						Glama.
-					</VSCodeLink>
-					If you're unsure which model to choose, Roo Code works best with{" "}
-					<VSCodeLink
-						style={{ display: "inline", fontSize: "inherit" }}
-						onClick={() => handleModelChange("anthropic/claude-3.5-sonnet")}>
-						anthropic/claude-3.5-sonnet.
-					</VSCodeLink>
-					You can also try searching "free" for no-cost options currently available.
-				</p>
-			)}
-		</>
-	)
-}
-
-export default GlamaModelPicker
-
-// Dropdown
-
-const DropdownWrapper = styled.div`
-	position: relative;
-	width: 100%;
-`
-
-export const GLAMA_MODEL_PICKER_Z_INDEX = 1_000
-
-const DropdownList = styled.div`
-	position: absolute;
-	top: calc(100% - 3px);
-	left: 0;
-	width: calc(100% - 2px);
-	max-height: 200px;
-	overflow-y: auto;
-	background-color: var(--vscode-dropdown-background);
-	border: 1px solid var(--vscode-list-activeSelectionBackground);
-	z-index: ${GLAMA_MODEL_PICKER_Z_INDEX - 1};
-	border-bottom-left-radius: 3px;
-	border-bottom-right-radius: 3px;
-`
-
-const DropdownItem = styled.div<{ isSelected: boolean }>`
-	padding: 5px 10px;
-	cursor: pointer;
-	word-break: break-all;
-	white-space: normal;
-
-	background-color: ${({ isSelected }) => (isSelected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")};
-
-	&:hover {
-		background-color: var(--vscode-list-activeSelectionBackground);
-	}
-`
-
-// Markdown
-
-const StyledMarkdown = styled.div`
-	font-family:
-		var(--vscode-font-family),
-		system-ui,
-		-apple-system,
-		BlinkMacSystemFont,
-		"Segoe UI",
-		Roboto,
-		Oxygen,
-		Ubuntu,
-		Cantarell,
-		"Open Sans",
-		"Helvetica Neue",
-		sans-serif;
-	font-size: 12px;
-	color: var(--vscode-descriptionForeground);
-
-	p,
-	li,
-	ol,
-	ul {
-		line-height: 1.25;
-		margin: 0;
-	}
-
-	ol,
-	ul {
-		padding-left: 1.5em;
-		margin-left: 0;
-	}
-
-	p {
-		white-space: pre-wrap;
-	}
-
-	a {
-		text-decoration: none;
-	}
-	a {
-		&:hover {
-			text-decoration: underline;
-		}
-	}
-`
-
-export const ModelDescriptionMarkdown = memo(
-	({
-		markdown,
-		key,
-		isExpanded,
-		setIsExpanded,
-	}: {
-		markdown?: string
-		key: string
-		isExpanded: boolean
-		setIsExpanded: (isExpanded: boolean) => void
-	}) => {
-		const [reactContent, setMarkdown] = useRemark()
-		const [showSeeMore, setShowSeeMore] = useState(false)
-		const textContainerRef = useRef<HTMLDivElement>(null)
-		const textRef = useRef<HTMLDivElement>(null)
-
-		useEffect(() => {
-			setMarkdown(markdown || "")
-		}, [markdown, setMarkdown])
-
-		useEffect(() => {
-			if (textRef.current && textContainerRef.current) {
-				const { scrollHeight } = textRef.current
-				const { clientHeight } = textContainerRef.current
-				const isOverflowing = scrollHeight > clientHeight
-				setShowSeeMore(isOverflowing)
-			}
-		}, [reactContent, setIsExpanded])
-
-		return (
-			<StyledMarkdown key={key} style={{ display: "inline-block", marginBottom: 0 }}>
-				<div
-					ref={textContainerRef}
-					style={{
-						overflowY: isExpanded ? "auto" : "hidden",
-						position: "relative",
-						wordBreak: "break-word",
-						overflowWrap: "anywhere",
-					}}>
-					<div
-						ref={textRef}
-						style={{
-							display: "-webkit-box",
-							WebkitLineClamp: isExpanded ? "unset" : 3,
-							WebkitBoxOrient: "vertical",
-							overflow: "hidden",
-						}}>
-						{reactContent}
-					</div>
-					{!isExpanded && showSeeMore && (
-						<div
-							style={{
-								position: "absolute",
-								right: 0,
-								bottom: 0,
-								display: "flex",
-								alignItems: "center",
-							}}>
-							<div
-								style={{
-									width: 30,
-									height: "1.2em",
-									background:
-										"linear-gradient(to right, transparent, var(--vscode-sideBar-background))",
-								}}
-							/>
-							<VSCodeLink
-								style={{
-									fontSize: "inherit",
-									paddingRight: 0,
-									paddingLeft: 3,
-									backgroundColor: "var(--vscode-sideBar-background)",
-								}}
-								onClick={() => setIsExpanded(true)}>
-								See more
-							</VSCodeLink>
-						</div>
-					)}
-				</div>
-			</StyledMarkdown>
-		)
-	},
+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-5-sonnet"
+	/>
 )

+ 90 - 0
webview-ui/src/components/settings/ModelDescriptionMarkdown.tsx

@@ -0,0 +1,90 @@
+import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
+import { memo, useEffect, useRef, useState } from "react"
+import { useRemark } from "react-remark"
+
+import { StyledMarkdown } from "./styles"
+
+export const ModelDescriptionMarkdown = memo(
+	({
+		markdown,
+		key,
+		isExpanded,
+		setIsExpanded,
+	}: {
+		markdown?: string
+		key: string
+		isExpanded: boolean
+		setIsExpanded: (isExpanded: boolean) => void
+	}) => {
+		const [reactContent, setMarkdown] = useRemark()
+		const [showSeeMore, setShowSeeMore] = useState(false)
+		const textContainerRef = useRef<HTMLDivElement>(null)
+		const textRef = useRef<HTMLDivElement>(null)
+
+		useEffect(() => {
+			setMarkdown(markdown || "")
+		}, [markdown, setMarkdown])
+
+		useEffect(() => {
+			if (textRef.current && textContainerRef.current) {
+				const { scrollHeight } = textRef.current
+				const { clientHeight } = textContainerRef.current
+				const isOverflowing = scrollHeight > clientHeight
+				setShowSeeMore(isOverflowing)
+			}
+		}, [reactContent, setIsExpanded])
+
+		return (
+			<StyledMarkdown key={key} style={{ display: "inline-block", marginBottom: 0 }}>
+				<div
+					ref={textContainerRef}
+					style={{
+						overflowY: isExpanded ? "auto" : "hidden",
+						position: "relative",
+						wordBreak: "break-word",
+						overflowWrap: "anywhere",
+					}}>
+					<div
+						ref={textRef}
+						style={{
+							display: "-webkit-box",
+							WebkitLineClamp: isExpanded ? "unset" : 3,
+							WebkitBoxOrient: "vertical",
+							overflow: "hidden",
+						}}>
+						{reactContent}
+					</div>
+					{!isExpanded && showSeeMore && (
+						<div
+							style={{
+								position: "absolute",
+								right: 0,
+								bottom: 0,
+								display: "flex",
+								alignItems: "center",
+							}}>
+							<div
+								style={{
+									width: 30,
+									height: "1.2em",
+									background:
+										"linear-gradient(to right, transparent, var(--vscode-sideBar-background))",
+								}}
+							/>
+							<VSCodeLink
+								style={{
+									fontSize: "inherit",
+									paddingRight: 0,
+									paddingLeft: 3,
+									backgroundColor: "var(--vscode-sideBar-background)",
+								}}
+								onClick={() => setIsExpanded(true)}>
+								See more
+							</VSCodeLink>
+						</div>
+					)}
+				</div>
+			</StyledMarkdown>
+		)
+	},
+)

+ 124 - 0
webview-ui/src/components/settings/ModelInfoView.tsx

@@ -0,0 +1,124 @@
+import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
+import { Fragment } from "react"
+
+import { ModelInfo, geminiModels } from "../../../../src/shared/api"
+import { ModelDescriptionMarkdown } from "./ModelDescriptionMarkdown"
+import { formatPrice } from "../../utils/formatPrice"
+
+export const ModelInfoView = ({
+	selectedModelId,
+	modelInfo,
+	isDescriptionExpanded,
+	setIsDescriptionExpanded,
+}: {
+	selectedModelId: string
+	modelInfo: ModelInfo
+	isDescriptionExpanded: boolean
+	setIsDescriptionExpanded: (isExpanded: boolean) => void
+}) => {
+	const isGemini = Object.keys(geminiModels).includes(selectedModelId)
+
+	const infoItems = [
+		modelInfo.description && (
+			<ModelDescriptionMarkdown
+				key="description"
+				markdown={modelInfo.description}
+				isExpanded={isDescriptionExpanded}
+				setIsExpanded={setIsDescriptionExpanded}
+			/>
+		),
+		<ModelInfoSupportsItem
+			isSupported={modelInfo.supportsImages ?? false}
+			supportsLabel="Supports images"
+			doesNotSupportLabel="Does not support images"
+		/>,
+		<ModelInfoSupportsItem
+			isSupported={modelInfo.supportsComputerUse ?? false}
+			supportsLabel="Supports computer use"
+			doesNotSupportLabel="Does not support computer use"
+		/>,
+		!isGemini && (
+			<ModelInfoSupportsItem
+				isSupported={modelInfo.supportsPromptCache}
+				supportsLabel="Supports prompt caching"
+				doesNotSupportLabel="Does not support prompt caching"
+			/>
+		),
+		modelInfo.maxTokens !== undefined && modelInfo.maxTokens > 0 && (
+			<span key="maxTokens">
+				<span style={{ fontWeight: 500 }}>Max output:</span> {modelInfo.maxTokens?.toLocaleString()} tokens
+			</span>
+		),
+		modelInfo.inputPrice !== undefined && modelInfo.inputPrice > 0 && (
+			<span key="inputPrice">
+				<span style={{ fontWeight: 500 }}>Input price:</span> {formatPrice(modelInfo.inputPrice)}/million tokens
+			</span>
+		),
+		modelInfo.supportsPromptCache && modelInfo.cacheWritesPrice && (
+			<span key="cacheWritesPrice">
+				<span style={{ fontWeight: 500 }}>Cache writes price:</span>{" "}
+				{formatPrice(modelInfo.cacheWritesPrice || 0)}/million tokens
+			</span>
+		),
+		modelInfo.supportsPromptCache && modelInfo.cacheReadsPrice && (
+			<span key="cacheReadsPrice">
+				<span style={{ fontWeight: 500 }}>Cache reads price:</span>{" "}
+				{formatPrice(modelInfo.cacheReadsPrice || 0)}/million tokens
+			</span>
+		),
+		modelInfo.outputPrice !== undefined && modelInfo.outputPrice > 0 && (
+			<span key="outputPrice">
+				<span style={{ fontWeight: 500 }}>Output price:</span> {formatPrice(modelInfo.outputPrice)}/million
+				tokens
+			</span>
+		),
+		isGemini && (
+			<span key="geminiInfo" style={{ fontStyle: "italic" }}>
+				* Free up to {selectedModelId && selectedModelId.includes("flash") ? "15" : "2"} requests per minute.
+				After that, billing depends on prompt size.{" "}
+				<VSCodeLink href="https://ai.google.dev/pricing" style={{ display: "inline", fontSize: "inherit" }}>
+					For more info, see pricing details.
+				</VSCodeLink>
+			</span>
+		),
+	].filter(Boolean)
+
+	return (
+		<div style={{ fontSize: "12px", marginTop: "2px", color: "var(--vscode-descriptionForeground)" }}>
+			{infoItems.map((item, index) => (
+				<Fragment key={index}>
+					{item}
+					{index < infoItems.length - 1 && <br />}
+				</Fragment>
+			))}
+		</div>
+	)
+}
+
+const ModelInfoSupportsItem = ({
+	isSupported,
+	supportsLabel,
+	doesNotSupportLabel,
+}: {
+	isSupported: boolean
+	supportsLabel: string
+	doesNotSupportLabel: string
+}) => (
+	<span
+		style={{
+			fontWeight: 500,
+			color: isSupported ? "var(--vscode-charts-green)" : "var(--vscode-errorForeground)",
+		}}>
+		<i
+			className={`codicon codicon-${isSupported ? "check" : "x"}`}
+			style={{
+				marginRight: 4,
+				marginBottom: isSupported ? 1 : -1,
+				fontSize: isSupported ? 11 : 13,
+				fontWeight: 700,
+				display: "inline-block",
+				verticalAlign: "bottom",
+			}}></i>
+		{isSupported ? supportsLabel : doesNotSupportLabel}
+	</span>
+)

+ 130 - 0
webview-ui/src/components/settings/ModelPicker.tsx

@@ -0,0 +1,130 @@
+import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
+import debounce from "debounce"
+import { useMemo, useState, useCallback, useEffect } from "react"
+import { useMount } from "react-use"
+import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"
+
+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"
+
+interface ModelPickerProps {
+	defaultModelId: string
+	modelsKey: "glamaModels" | "openRouterModels"
+	configKey: "glamaModelId" | "openRouterModelId"
+	infoKey: "glamaModelInfo" | "openRouterModelInfo"
+	refreshMessageType: "refreshGlamaModels" | "refreshOpenRouterModels"
+	serviceName: string
+	serviceUrl: string
+	recommendedModel: string
+}
+
+export const ModelPicker = ({
+	defaultModelId,
+	modelsKey,
+	configKey,
+	infoKey,
+	refreshMessageType,
+	serviceName,
+	serviceUrl,
+	recommendedModel,
+}: ModelPickerProps) => {
+	const [open, setOpen] = useState(false)
+	const [value, setValue] = useState(defaultModelId)
+	const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
+
+	const { apiConfiguration, setApiConfiguration, [modelsKey]: models, onUpdateApiConfig } = useExtensionState()
+	const modelIds = useMemo(() => Object.keys(models).sort((a, b) => a.localeCompare(b)), [models])
+
+	const { selectedModelId, selectedModelInfo } = useMemo(
+		() => normalizeApiConfiguration(apiConfiguration),
+		[apiConfiguration],
+	)
+
+	const onSelect = useCallback(
+		(modelId: string) => {
+			const apiConfig = { ...apiConfiguration, [configKey]: modelId, [infoKey]: models[modelId] }
+			setApiConfiguration(apiConfig)
+			onUpdateApiConfig(apiConfig)
+			setValue(modelId)
+			setOpen(false)
+		},
+		[apiConfiguration, configKey, infoKey, models, onUpdateApiConfig, setApiConfiguration],
+	)
+
+	const debouncedRefreshModels = useMemo(
+		() => debounce(() => vscode.postMessage({ type: refreshMessageType }), 50),
+		[refreshMessageType],
+	)
+
+	useMount(() => {
+		debouncedRefreshModels()
+		return () => debouncedRefreshModels.clear()
+	})
+
+	useEffect(() => setValue(selectedModelId), [selectedModelId])
+
+	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>
+						</CommandList>
+					</Command>
+				</PopoverContent>
+			</Popover>
+			{selectedModelId && selectedModelInfo && (
+				<ModelInfoView
+					selectedModelId={selectedModelId}
+					modelInfo={selectedModelInfo}
+					isDescriptionExpanded={isDescriptionExpanded}
+					setIsDescriptionExpanded={setIsDescriptionExpanded}
+				/>
+			)}
+			<p>
+				The extension automatically fetches the latest list of models available on{" "}
+				<VSCodeLink style={{ display: "inline", fontSize: "inherit" }} href={serviceUrl}>
+					{serviceName}.
+				</VSCodeLink>
+				If you're unsure which model to choose, Roo Code works best with{" "}
+				<VSCodeLink onClick={() => onSelect(recommendedModel)}>{recommendedModel}.</VSCodeLink>
+				You can also try searching "free" for no-cost options currently available.
+			</p>
+		</>
+	)
+}

+ 8 - 180
webview-ui/src/components/settings/OpenAiModelPicker.tsx

@@ -1,12 +1,12 @@
-import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
-import { Fzf } from "fzf"
-import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react"
+import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
 import debounce from "debounce"
-import { useRemark } from "react-remark"
-import styled from "styled-components"
+import { Fzf } from "fzf"
+import React, { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"
+
 import { useExtensionState } from "../../context/ExtensionStateContext"
 import { vscode } from "../../utils/vscode"
 import { highlightFzfMatch } from "../../utils/highlight"
+import { DropdownWrapper, DropdownList, DropdownItem } from "./styles"
 
 const OpenAiModelPicker: React.FC = () => {
 	const { apiConfiguration, setApiConfiguration, openAiModels, onUpdateApiConfig } = useExtensionState()
@@ -23,6 +23,7 @@ const OpenAiModelPicker: React.FC = () => {
 			...apiConfiguration,
 			openAiModelId: newModelId,
 		}
+
 		setApiConfiguration(apiConfig)
 		onUpdateApiConfig(apiConfig)
 		setSearchTerm(newModelId)
@@ -185,12 +186,12 @@ const OpenAiModelPicker: React.FC = () => {
 						)}
 					</VSCodeTextField>
 					{isDropdownVisible && (
-						<DropdownList ref={dropdownListRef}>
+						<DropdownList ref={dropdownListRef} $zIndex={OPENAI_MODEL_PICKER_Z_INDEX - 1}>
 							{modelSearchResults.map((item, index) => (
 								<DropdownItem
+									$selected={index === selectedIndex}
 									key={item.id}
 									ref={(el) => (itemRefs.current[index] = el)}
-									isSelected={index === selectedIndex}
 									onMouseEnter={() => setSelectedIndex(index)}
 									onClick={() => {
 										handleModelChange(item.id)
@@ -213,177 +214,4 @@ export default OpenAiModelPicker
 
 // Dropdown
 
-const DropdownWrapper = styled.div`
-	position: relative;
-	width: 100%;
-`
-
 export const OPENAI_MODEL_PICKER_Z_INDEX = 1_000
-
-const DropdownList = styled.div`
-	position: absolute;
-	top: calc(100% - 3px);
-	left: 0;
-	width: calc(100% - 2px);
-	max-height: 200px;
-	overflow-y: auto;
-	background-color: var(--vscode-dropdown-background);
-	border: 1px solid var(--vscode-list-activeSelectionBackground);
-	z-index: ${OPENAI_MODEL_PICKER_Z_INDEX - 1};
-	border-bottom-left-radius: 3px;
-	border-bottom-right-radius: 3px;
-`
-
-const DropdownItem = styled.div<{ isSelected: boolean }>`
-	padding: 5px 10px;
-	cursor: pointer;
-	word-break: break-all;
-	white-space: normal;
-
-	background-color: ${({ isSelected }) => (isSelected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")};
-
-	&:hover {
-		background-color: var(--vscode-list-activeSelectionBackground);
-	}
-`
-
-// Markdown
-
-const StyledMarkdown = styled.div`
-	font-family:
-		var(--vscode-font-family),
-		system-ui,
-		-apple-system,
-		BlinkMacSystemFont,
-		"Segoe UI",
-		Roboto,
-		Oxygen,
-		Ubuntu,
-		Cantarell,
-		"Open Sans",
-		"Helvetica Neue",
-		sans-serif;
-	font-size: 12px;
-	color: var(--vscode-descriptionForeground);
-
-	p,
-	li,
-	ol,
-	ul {
-		line-height: 1.25;
-		margin: 0;
-	}
-
-	ol,
-	ul {
-		padding-left: 1.5em;
-		margin-left: 0;
-	}
-
-	p {
-		white-space: pre-wrap;
-	}
-
-	a {
-		text-decoration: none;
-	}
-	a {
-		&:hover {
-			text-decoration: underline;
-		}
-	}
-`
-
-export const ModelDescriptionMarkdown = memo(
-	({
-		markdown,
-		key,
-		isExpanded,
-		setIsExpanded,
-	}: {
-		markdown?: string
-		key: string
-		isExpanded: boolean
-		setIsExpanded: (isExpanded: boolean) => void
-	}) => {
-		const [reactContent, setMarkdown] = useRemark()
-		// const [isExpanded, setIsExpanded] = useState(false)
-		const [showSeeMore, setShowSeeMore] = useState(false)
-		const textContainerRef = useRef<HTMLDivElement>(null)
-		const textRef = useRef<HTMLDivElement>(null)
-
-		useEffect(() => {
-			setMarkdown(markdown || "")
-		}, [markdown, setMarkdown])
-
-		useEffect(() => {
-			if (textRef.current && textContainerRef.current) {
-				const { scrollHeight } = textRef.current
-				const { clientHeight } = textContainerRef.current
-				const isOverflowing = scrollHeight > clientHeight
-				setShowSeeMore(isOverflowing)
-				// if (!isOverflowing) {
-				// 	setIsExpanded(false)
-				// }
-			}
-		}, [reactContent, setIsExpanded])
-
-		return (
-			<StyledMarkdown key={key} style={{ display: "inline-block", marginBottom: 0 }}>
-				<div
-					ref={textContainerRef}
-					style={{
-						overflowY: isExpanded ? "auto" : "hidden",
-						position: "relative",
-						wordBreak: "break-word",
-						overflowWrap: "anywhere",
-					}}>
-					<div
-						ref={textRef}
-						style={{
-							display: "-webkit-box",
-							WebkitLineClamp: isExpanded ? "unset" : 3,
-							WebkitBoxOrient: "vertical",
-							overflow: "hidden",
-							// whiteSpace: "pre-wrap",
-							// wordBreak: "break-word",
-							// overflowWrap: "anywhere",
-						}}>
-						{reactContent}
-					</div>
-					{!isExpanded && showSeeMore && (
-						<div
-							style={{
-								position: "absolute",
-								right: 0,
-								bottom: 0,
-								display: "flex",
-								alignItems: "center",
-							}}>
-							<div
-								style={{
-									width: 30,
-									height: "1.2em",
-									background:
-										"linear-gradient(to right, transparent, var(--vscode-sideBar-background))",
-								}}
-							/>
-							<VSCodeLink
-								style={{
-									// cursor: "pointer",
-									// color: "var(--vscode-textLink-foreground)",
-									fontSize: "inherit",
-									paddingRight: 0,
-									paddingLeft: 3,
-									backgroundColor: "var(--vscode-sideBar-background)",
-								}}
-								onClick={() => setIsExpanded(true)}>
-								See more
-							</VSCodeLink>
-						</div>
-					)}
-				</div>
-			</StyledMarkdown>
-		)
-	},
-)

+ 12 - 434
webview-ui/src/components/settings/OpenRouterModelPicker.tsx

@@ -1,437 +1,15 @@
-import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
-import debounce from "debounce"
-import { Fzf } from "fzf"
-import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react"
-import { useRemark } from "react-remark"
-import { useMount } from "react-use"
-import styled from "styled-components"
+import { ModelPicker } from "./ModelPicker"
 import { openRouterDefaultModelId } from "../../../../src/shared/api"
-import { useExtensionState } from "../../context/ExtensionStateContext"
-import { vscode } from "../../utils/vscode"
-import { highlightFzfMatch } from "../../utils/highlight"
-import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
 
-const OpenRouterModelPicker: React.FC = () => {
-	const { apiConfiguration, setApiConfiguration, openRouterModels, onUpdateApiConfig } = useExtensionState()
-	const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openRouterModelId || openRouterDefaultModelId)
-	const [isDropdownVisible, setIsDropdownVisible] = useState(false)
-	const [selectedIndex, setSelectedIndex] = useState(-1)
-	const dropdownRef = useRef<HTMLDivElement>(null)
-	const itemRefs = useRef<(HTMLDivElement | null)[]>([])
-	const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
-	const dropdownListRef = useRef<HTMLDivElement>(null)
-
-	const handleModelChange = (newModelId: string) => {
-		// could be setting invalid model id/undefined info but validation will catch it
-		const apiConfig = {
-			...apiConfiguration,
-			openRouterModelId: newModelId,
-			openRouterModelInfo: openRouterModels[newModelId],
-		}
-
-		setApiConfiguration(apiConfig)
-		onUpdateApiConfig(apiConfig)
-		setSearchTerm(newModelId)
-	}
-
-	const { selectedModelId, selectedModelInfo } = useMemo(() => {
-		return normalizeApiConfiguration(apiConfiguration)
-	}, [apiConfiguration])
-
-	useEffect(() => {
-		if (apiConfiguration?.openRouterModelId && apiConfiguration?.openRouterModelId !== searchTerm) {
-			setSearchTerm(apiConfiguration?.openRouterModelId)
-		}
-	}, [apiConfiguration, searchTerm])
-
-	const debouncedRefreshModels = useMemo(
-		() =>
-			debounce(() => {
-				vscode.postMessage({ type: "refreshOpenRouterModels" })
-			}, 50),
-		[],
-	)
-
-	useMount(() => {
-		debouncedRefreshModels()
-
-		// Cleanup debounced function
-		return () => {
-			debouncedRefreshModels.clear()
-		}
-	})
-
-	useEffect(() => {
-		const handleClickOutside = (event: MouseEvent) => {
-			if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
-				setIsDropdownVisible(false)
-			}
-		}
-
-		document.addEventListener("mousedown", handleClickOutside)
-		return () => {
-			document.removeEventListener("mousedown", handleClickOutside)
-		}
-	}, [])
-
-	const modelIds = useMemo(() => {
-		return Object.keys(openRouterModels).sort((a, b) => a.localeCompare(b))
-	}, [openRouterModels])
-
-	const searchableItems = useMemo(() => {
-		return modelIds.map((id) => ({
-			id,
-			html: id,
-		}))
-	}, [modelIds])
-
-	const fzf = useMemo(() => {
-		return new Fzf(searchableItems, {
-			selector: (item) => item.html,
-		})
-	}, [searchableItems])
-
-	const modelSearchResults = useMemo(() => {
-		if (!searchTerm) return searchableItems
-
-		const searchResults = fzf.find(searchTerm)
-		return searchResults.map((result) => ({
-			...result.item,
-			html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight"),
-		}))
-	}, [searchableItems, searchTerm, fzf])
-
-	const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
-		if (!isDropdownVisible) return
-
-		switch (event.key) {
-			case "ArrowDown":
-				event.preventDefault()
-				setSelectedIndex((prev) => (prev < modelSearchResults.length - 1 ? prev + 1 : prev))
-				break
-			case "ArrowUp":
-				event.preventDefault()
-				setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev))
-				break
-			case "Enter":
-				event.preventDefault()
-				if (selectedIndex >= 0 && selectedIndex < modelSearchResults.length) {
-					handleModelChange(modelSearchResults[selectedIndex].id)
-					setIsDropdownVisible(false)
-				}
-				break
-			case "Escape":
-				setIsDropdownVisible(false)
-				setSelectedIndex(-1)
-				break
-		}
-	}
-
-	const hasInfo = useMemo(() => {
-		return modelIds.some((id) => id.toLowerCase() === searchTerm.toLowerCase())
-	}, [modelIds, searchTerm])
-
-	useEffect(() => {
-		setSelectedIndex(-1)
-		if (dropdownListRef.current) {
-			dropdownListRef.current.scrollTop = 0
-		}
-	}, [searchTerm])
-
-	useEffect(() => {
-		if (selectedIndex >= 0 && itemRefs.current[selectedIndex]) {
-			itemRefs.current[selectedIndex]?.scrollIntoView({
-				block: "nearest",
-				behavior: "smooth",
-			})
-		}
-	}, [selectedIndex])
-
-	return (
-		<>
-			<style>
-				{`
-				.model-item-highlight {
-					background-color: var(--vscode-editor-findMatchHighlightBackground);
-					color: inherit;
-				}
-				`}
-			</style>
-			<div>
-				<label htmlFor="model-search">
-					<span style={{ fontWeight: 500 }}>Model</span>
-				</label>
-				<DropdownWrapper ref={dropdownRef}>
-					<VSCodeTextField
-						id="model-search"
-						placeholder="Search and select a model..."
-						value={searchTerm}
-						onInput={(e) => {
-							handleModelChange((e.target as HTMLInputElement)?.value?.toLowerCase())
-							setIsDropdownVisible(true)
-						}}
-						onFocus={() => setIsDropdownVisible(true)}
-						onKeyDown={handleKeyDown}
-						style={{ width: "100%", zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX, position: "relative" }}>
-						{searchTerm && (
-							<div
-								className="input-icon-button codicon codicon-close"
-								aria-label="Clear search"
-								onClick={() => {
-									handleModelChange("")
-									setIsDropdownVisible(true)
-								}}
-								slot="end"
-								style={{
-									display: "flex",
-									justifyContent: "center",
-									alignItems: "center",
-									height: "100%",
-								}}
-							/>
-						)}
-					</VSCodeTextField>
-					{isDropdownVisible && (
-						<DropdownList ref={dropdownListRef}>
-							{modelSearchResults.map((item, index) => (
-								<DropdownItem
-									key={item.id}
-									ref={(el) => (itemRefs.current[index] = el)}
-									isSelected={index === selectedIndex}
-									onMouseEnter={() => setSelectedIndex(index)}
-									onClick={() => {
-										handleModelChange(item.id)
-										setIsDropdownVisible(false)
-									}}
-									dangerouslySetInnerHTML={{
-										__html: item.html,
-									}}
-								/>
-							))}
-						</DropdownList>
-					)}
-				</DropdownWrapper>
-			</div>
-
-			{hasInfo ? (
-				<ModelInfoView
-					selectedModelId={selectedModelId}
-					modelInfo={selectedModelInfo}
-					isDescriptionExpanded={isDescriptionExpanded}
-					setIsDescriptionExpanded={setIsDescriptionExpanded}
-				/>
-			) : (
-				<p
-					style={{
-						fontSize: "12px",
-						marginTop: 0,
-						color: "var(--vscode-descriptionForeground)",
-					}}>
-					The extension automatically fetches the latest list of models available on{" "}
-					<VSCodeLink style={{ display: "inline", fontSize: "inherit" }} href="https://openrouter.ai/models">
-						OpenRouter.
-					</VSCodeLink>
-					If you're unsure which model to choose, Roo Code works best with{" "}
-					<VSCodeLink
-						style={{ display: "inline", fontSize: "inherit" }}
-						onClick={() => handleModelChange("anthropic/claude-3.5-sonnet:beta")}>
-						anthropic/claude-3.5-sonnet:beta.
-					</VSCodeLink>
-					You can also try searching "free" for no-cost options currently available.
-				</p>
-			)}
-		</>
-	)
-}
-
-export default OpenRouterModelPicker
-
-// Dropdown
-
-const DropdownWrapper = styled.div`
-	position: relative;
-	width: 100%;
-`
-
-export const OPENROUTER_MODEL_PICKER_Z_INDEX = 1_000
-
-const DropdownList = styled.div`
-	position: absolute;
-	top: calc(100% - 3px);
-	left: 0;
-	width: calc(100% - 2px);
-	max-height: 200px;
-	overflow-y: auto;
-	background-color: var(--vscode-dropdown-background);
-	border: 1px solid var(--vscode-list-activeSelectionBackground);
-	z-index: ${OPENROUTER_MODEL_PICKER_Z_INDEX - 1};
-	border-bottom-left-radius: 3px;
-	border-bottom-right-radius: 3px;
-`
-
-const DropdownItem = styled.div<{ isSelected: boolean }>`
-	padding: 5px 10px;
-	cursor: pointer;
-	word-break: break-all;
-	white-space: normal;
-
-	background-color: ${({ isSelected }) => (isSelected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")};
-
-	&:hover {
-		background-color: var(--vscode-list-activeSelectionBackground);
-	}
-`
-
-// Markdown
-
-const StyledMarkdown = styled.div`
-	font-family:
-		var(--vscode-font-family),
-		system-ui,
-		-apple-system,
-		BlinkMacSystemFont,
-		"Segoe UI",
-		Roboto,
-		Oxygen,
-		Ubuntu,
-		Cantarell,
-		"Open Sans",
-		"Helvetica Neue",
-		sans-serif;
-	font-size: 12px;
-	color: var(--vscode-descriptionForeground);
-
-	p,
-	li,
-	ol,
-	ul {
-		line-height: 1.25;
-		margin: 0;
-	}
-
-	ol,
-	ul {
-		padding-left: 1.5em;
-		margin-left: 0;
-	}
-
-	p {
-		white-space: pre-wrap;
-	}
-
-	a {
-		text-decoration: none;
-	}
-	a {
-		&:hover {
-			text-decoration: underline;
-		}
-	}
-`
-
-export const ModelDescriptionMarkdown = memo(
-	({
-		markdown,
-		key,
-		isExpanded,
-		setIsExpanded,
-	}: {
-		markdown?: string
-		key: string
-		isExpanded: boolean
-		setIsExpanded: (isExpanded: boolean) => void
-	}) => {
-		const [reactContent, setMarkdown] = useRemark()
-		// const [isExpanded, setIsExpanded] = useState(false)
-		const [showSeeMore, setShowSeeMore] = useState(false)
-		const textContainerRef = useRef<HTMLDivElement>(null)
-		const textRef = useRef<HTMLDivElement>(null)
-
-		useEffect(() => {
-			setMarkdown(markdown || "")
-		}, [markdown, setMarkdown])
-
-		useEffect(() => {
-			if (textRef.current && textContainerRef.current) {
-				const { scrollHeight } = textRef.current
-				const { clientHeight } = textContainerRef.current
-				const isOverflowing = scrollHeight > clientHeight
-				setShowSeeMore(isOverflowing)
-				// if (!isOverflowing) {
-				// 	setIsExpanded(false)
-				// }
-			}
-		}, [reactContent, setIsExpanded])
-
-		return (
-			<StyledMarkdown key={key} style={{ display: "inline-block", marginBottom: 0 }}>
-				<div
-					ref={textContainerRef}
-					style={{
-						overflowY: isExpanded ? "auto" : "hidden",
-						position: "relative",
-						wordBreak: "break-word",
-						overflowWrap: "anywhere",
-					}}>
-					<div
-						ref={textRef}
-						style={{
-							display: "-webkit-box",
-							WebkitLineClamp: isExpanded ? "unset" : 3,
-							WebkitBoxOrient: "vertical",
-							overflow: "hidden",
-							// whiteSpace: "pre-wrap",
-							// wordBreak: "break-word",
-							// overflowWrap: "anywhere",
-						}}>
-						{reactContent}
-					</div>
-					{!isExpanded && showSeeMore && (
-						<div
-							style={{
-								position: "absolute",
-								right: 0,
-								bottom: 0,
-								display: "flex",
-								alignItems: "center",
-							}}>
-							<div
-								style={{
-									width: 30,
-									height: "1.2em",
-									background:
-										"linear-gradient(to right, transparent, var(--vscode-sideBar-background))",
-								}}
-							/>
-							<VSCodeLink
-								style={{
-									// cursor: "pointer",
-									// color: "var(--vscode-textLink-foreground)",
-									fontSize: "inherit",
-									paddingRight: 0,
-									paddingLeft: 3,
-									backgroundColor: "var(--vscode-sideBar-background)",
-								}}
-								onClick={() => setIsExpanded(true)}>
-								See more
-							</VSCodeLink>
-						</div>
-					)}
-				</div>
-				{/* {isExpanded && showSeeMore && (
-				<div
-					style={{
-						cursor: "pointer",
-						color: "var(--vscode-textLink-foreground)",
-						marginLeft: "auto",
-						textAlign: "right",
-						paddingRight: 2,
-					}}
-					onClick={() => setIsExpanded(false)}>
-					See less
-				</div>
-			)} */}
-			</StyledMarkdown>
-		)
-	},
+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.5-sonnet:beta"
+	/>
 )

+ 86 - 0
webview-ui/src/components/settings/__tests__/ModelPicker.test.tsx

@@ -0,0 +1,86 @@
+// cd webview-ui && npx jest src/components/settings/__tests__/ModelPicker.test.ts
+
+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(),
+}))
+
+class MockResizeObserver {
+	observe() {}
+	unobserve() {}
+	disconnect() {}
+}
+
+global.ResizeObserver = MockResizeObserver
+
+Element.prototype.scrollIntoView = jest.fn()
+
+describe("ModelPicker", () => {
+	const mockOnUpdateApiConfig = jest.fn()
+	const mockSetApiConfiguration = jest.fn()
+
+	const defaultProps = {
+		defaultModelId: "model1",
+		modelsKey: "glamaModels" as const,
+		configKey: "glamaModelId" as const,
+		infoKey: "glamaModelInfo" as const,
+		refreshMessageType: "refreshGlamaModels" 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" },
+	}
+
+	beforeEach(() => {
+		jest.clearAllMocks()
+		;(useExtensionState as jest.Mock).mockReturnValue({
+			apiConfiguration: {},
+			setApiConfiguration: mockSetApiConfiguration,
+			glamaModels: mockModels,
+			onUpdateApiConfig: mockOnUpdateApiConfig,
+		})
+	})
+
+	it("calls onUpdateApiConfig when a model is selected", async () => {
+		await act(async () => {
+			render(<ModelPicker {...defaultProps} />)
+		})
+
+		await act(async () => {
+			// Open the popover by clicking the button.
+			const button = screen.getByRole("combobox")
+			fireEvent.click(button)
+		})
+
+		// Wait for popover to open and animations to complete.
+		await act(async () => {
+			await new Promise((resolve) => setTimeout(resolve, 100))
+		})
+
+		await act(async () => {
+			// Find and click the model item by its value.
+			const modelItem = screen.getByRole("option", { name: "model2" })
+			fireEvent.click(modelItem)
+		})
+
+		// 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"],
+		})
+	})
+})

+ 80 - 0
webview-ui/src/components/settings/styles.ts

@@ -0,0 +1,80 @@
+import styled from "styled-components"
+
+export const DROPDOWN_Z_INDEX = 1_000
+
+export const DropdownWrapper = styled.div`
+	position: relative;
+	width: 100%;
+`
+
+export const DropdownList = styled.div<{ $zIndex: number }>`
+	position: absolute;
+	top: calc(100% - 3px);
+	left: 0;
+	width: calc(100% - 2px);
+	max-height: 200px;
+	overflow-y: auto;
+	background-color: var(--vscode-dropdown-background);
+	border: 1px solid var(--vscode-list-activeSelectionBackground);
+	z-index: ${({ $zIndex }) => $zIndex};
+	border-bottom-left-radius: 3px;
+	border-bottom-right-radius: 3px;
+`
+
+export const DropdownItem = styled.div<{ $selected: boolean }>`
+	padding: 5px 10px;
+	cursor: pointer;
+	word-break: break-all;
+	white-space: normal;
+
+	background-color: ${({ $selected }) => ($selected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")};
+
+	&:hover {
+		background-color: var(--vscode-list-activeSelectionBackground);
+	}
+`
+
+export const StyledMarkdown = styled.div`
+	font-family:
+		var(--vscode-font-family),
+		system-ui,
+		-apple-system,
+		BlinkMacSystemFont,
+		"Segoe UI",
+		Roboto,
+		Oxygen,
+		Ubuntu,
+		Cantarell,
+		"Open Sans",
+		"Helvetica Neue",
+		sans-serif;
+	font-size: 12px;
+	color: var(--vscode-descriptionForeground);
+
+	p,
+	li,
+	ol,
+	ul {
+		line-height: 1.25;
+		margin: 0;
+	}
+
+	ol,
+	ul {
+		padding-left: 1.5em;
+		margin-left: 0;
+	}
+
+	p {
+		white-space: pre-wrap;
+	}
+
+	a {
+		text-decoration: none;
+	}
+	a {
+		&:hover {
+			text-decoration: underline;
+		}
+	}
+`

+ 5 - 2
webview-ui/src/components/ui/button.tsx

@@ -10,11 +10,14 @@ const buttonVariants = cva(
 		variants: {
 			variant: {
 				default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
-				destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
-				outline: "border border-input bg-foreground shadow-sm hover:bg-foreground/80",
 				secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
+				outline:
+					"border border-vscode-dropdown-border bg-vscode-background shadow-sm hover:border-vscode-dropdown-border/80",
 				ghost: "hover:bg-accent hover:text-accent-foreground",
 				link: "text-primary underline-offset-4 hover:underline",
+				destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
+				combobox:
+					"bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border",
 			},
 			size: {
 				default: "h-7 px-3",

+ 7 - 3
webview-ui/src/components/ui/command.tsx

@@ -38,7 +38,7 @@ const CommandInput = React.forwardRef<
 	React.ElementRef<typeof CommandPrimitive.Input>,
 	React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
 >(({ className, ...props }, ref) => (
-	<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
+	<div className="flex items-center border-b border-vscode-dropdown-border px-3" cmdk-input-wrapper="">
 		<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
 		<CommandPrimitive.Input
 			ref={ref}
@@ -93,7 +93,11 @@ const CommandSeparator = React.forwardRef<
 	React.ElementRef<typeof CommandPrimitive.Separator>,
 	React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
 >(({ className, ...props }, ref) => (
-	<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
+	<CommandPrimitive.Separator
+		ref={ref}
+		className={cn("-mx-1 h-px bg-vscode-dropdown-border", className)}
+		{...props}
+	/>
 ))
 CommandSeparator.displayName = CommandPrimitive.Separator.displayName
 
@@ -104,7 +108,7 @@ const CommandItem = React.forwardRef<
 	<CommandPrimitive.Item
 		ref={ref}
 		className={cn(
-			"relative flex cursor-default gap-2 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-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+			"relative flex cursor-pointer gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm text-vscode-dropdown-foreground outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
 			className,
 		)}
 		{...props}

+ 1 - 1
webview-ui/src/components/ui/popover.tsx

@@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
 			align={align}
 			sideOffset={sideOffset}
 			className={cn(
-				"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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",
+				"z-50 w-72 rounded-xs border border-vscode-dropdown-border bg-popover p-4 text-popover-foreground shadow-md outline-none 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}

+ 22 - 0
webview-ui/src/index.css

@@ -54,6 +54,28 @@
 	--radius-lg: var(--radius);
 	--radius-md: calc(var(--radius) - 2px);
 	--radius-sm: calc(var(--radius) - 4px);
+
+	/**
+	 * Allow VSCode colors to be used with Tailwind.
+	 */
+
+	--color-vscode-foreground: var(--vscode-foreground);
+	--color-vscode-background: var(--vscode-background);
+
+	--color-vscode-editor-foreground: var(--vscode-editor-foreground);
+	--color-vscode-editor-background: var(--vscode-editor-background);
+
+	--color-vscode-button-foreground: var(--vscode-button-foreground);
+	--color-vscode-button-background: var(--vscode-button-background);
+	--color-vscode-button-secondaryForeground: var(--vscode-button-secondaryForeground);
+	--color-vscode-button-secondaryBackground: var(--vscode-button-secondaryBackground);
+
+	--color-vscode-dropdown-foreground: var(--vscode-dropdown-foreground);
+	--color-vscode-dropdown-background: var(--vscode-dropdown-background);
+	--color-vscode-dropdown-border: var(--vscode-dropdown-border);
+
+	--color-vscode-input-background: var(--vscode-input-background);
+	--color-vscode-input-border: var(--vscode-input-border);
 }
 
 @layer base {

+ 8 - 0
webview-ui/src/utils/formatPrice.ts

@@ -0,0 +1,8 @@
+export const formatPrice = (price: number) => {
+	return new Intl.NumberFormat("en-US", {
+		style: "currency",
+		currency: "USD",
+		minimumFractionDigits: 2,
+		maximumFractionDigits: 2,
+	}).format(price)
+}