Browse Source

Merge branch 'main' into pm/unbound-fetch-models

Vignesh Subbiah 1 year ago
parent
commit
70ed217149
44 changed files with 1968 additions and 1514 deletions
  1. 5 0
      .changeset/cyan-insects-marry.md
  2. 5 0
      .changeset/dirty-coins-exist.md
  3. 5 0
      .changeset/violet-rockets-fetch.md
  4. 1 1
      src/api/providers/__tests__/deepseek.test.ts
  5. 1 1
      src/api/providers/__tests__/gemini.test.ts
  6. 1 4
      src/api/providers/deepseek.ts
  7. 1 4
      src/api/providers/gemini.ts
  8. 3 4
      src/api/providers/glama.ts
  9. 10 15
      src/api/providers/openai-native.ts
  10. 5 6
      src/api/providers/openai.ts
  11. 10 8
      src/api/providers/openrouter.ts
  12. 3 4
      src/api/providers/unbound.ts
  13. 2 2
      src/api/providers/vertex.ts
  14. 6 4
      src/core/Cline.ts
  15. 29 2
      src/core/webview/ClineProvider.ts
  16. 7 2
      src/extension.ts
  17. 317 0
      src/services/checkpoints/CheckpointService.ts
  18. 337 0
      src/services/checkpoints/__tests__/CheckpointService.test.ts
  19. 83 0
      src/services/mcp/McpServerManager.ts
  20. 1 1
      webview-ui/jest.config.cjs
  21. 38 142
      webview-ui/package-lock.json
  22. 3 3
      webview-ui/package.json
  23. 12 9
      webview-ui/src/components/chat/ChatRow.tsx
  24. 40 2
      webview-ui/src/components/history/HistoryPreview.tsx
  25. 4 14
      webview-ui/src/components/history/HistoryView.tsx
  26. 42 26
      webview-ui/src/components/history/__tests__/HistoryView.test.tsx
  27. 7 8
      webview-ui/src/components/mcp/McpView.tsx
  28. 195 65
      webview-ui/src/components/settings/ApiConfigManager.tsx
  29. 12 142
      webview-ui/src/components/settings/ApiOptions.tsx
  30. 12 412
      webview-ui/src/components/settings/GlamaModelPicker.tsx
  31. 90 0
      webview-ui/src/components/settings/ModelDescriptionMarkdown.tsx
  32. 124 0
      webview-ui/src/components/settings/ModelInfoView.tsx
  33. 130 0
      webview-ui/src/components/settings/ModelPicker.tsx
  34. 8 180
      webview-ui/src/components/settings/OpenAiModelPicker.tsx
  35. 12 434
      webview-ui/src/components/settings/OpenRouterModelPicker.tsx
  36. 141 13
      webview-ui/src/components/settings/__tests__/ApiConfigManager.test.tsx
  37. 86 0
      webview-ui/src/components/settings/__tests__/ModelPicker.test.tsx
  38. 80 0
      webview-ui/src/components/settings/styles.ts
  39. 5 2
      webview-ui/src/components/ui/button.tsx
  40. 7 3
      webview-ui/src/components/ui/command.tsx
  41. 1 1
      webview-ui/src/components/ui/popover.tsx
  42. 22 0
      webview-ui/src/index.css
  43. 57 0
      webview-ui/src/utils/clipboard.ts
  44. 8 0
      webview-ui/src/utils/formatPrice.ts

+ 5 - 0
.changeset/cyan-insects-marry.md

@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Add a copy button to the recent tasks

+ 5 - 0
.changeset/dirty-coins-exist.md

@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Improve the user experience for adding a new configuration profile

+ 5 - 0
.changeset/violet-rockets-fetch.md

@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+v3.3.15

+ 1 - 1
src/api/providers/__tests__/deepseek.test.ts

@@ -84,7 +84,7 @@ describe("DeepSeekHandler", () => {
 			expect(handler.getModel().id).toBe(mockOptions.apiModelId)
 		})
 
-		it("should throw error if API key is missing", () => {
+		it.skip("should throw error if API key is missing", () => {
 			expect(() => {
 				new DeepSeekHandler({
 					...mockOptions,

+ 1 - 1
src/api/providers/__tests__/gemini.test.ts

@@ -33,7 +33,7 @@ describe("GeminiHandler", () => {
 			expect(handler["options"].apiModelId).toBe("gemini-2.0-flash-thinking-exp-1219")
 		})
 
-		it("should throw if API key is missing", () => {
+		it.skip("should throw if API key is missing", () => {
 			expect(() => {
 				new GeminiHandler({
 					apiModelId: "gemini-2.0-flash-thinking-exp-1219",

+ 1 - 4
src/api/providers/deepseek.ts

@@ -4,12 +4,9 @@ import { deepSeekModels, deepSeekDefaultModelId } from "../../shared/api"
 
 export class DeepSeekHandler extends OpenAiHandler {
 	constructor(options: ApiHandlerOptions) {
-		if (!options.deepSeekApiKey) {
-			throw new Error("DeepSeek API key is required. Please provide it in the settings.")
-		}
 		super({
 			...options,
-			openAiApiKey: options.deepSeekApiKey,
+			openAiApiKey: options.deepSeekApiKey ?? "not-provided",
 			openAiModelId: options.apiModelId ?? deepSeekDefaultModelId,
 			openAiBaseUrl: options.deepSeekBaseUrl ?? "https://api.deepseek.com/v1",
 			openAiStreamingEnabled: true,

+ 1 - 4
src/api/providers/gemini.ts

@@ -10,11 +10,8 @@ export class GeminiHandler implements ApiHandler, SingleCompletionHandler {
 	private client: GoogleGenerativeAI
 
 	constructor(options: ApiHandlerOptions) {
-		if (!options.geminiApiKey) {
-			throw new Error("API key is required for Google Gemini")
-		}
 		this.options = options
-		this.client = new GoogleGenerativeAI(options.geminiApiKey)
+		this.client = new GoogleGenerativeAI(options.geminiApiKey ?? "not-provided")
 	}
 
 	async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {

+ 3 - 4
src/api/providers/glama.ts

@@ -13,10 +13,9 @@ export class GlamaHandler implements ApiHandler, SingleCompletionHandler {
 
 	constructor(options: ApiHandlerOptions) {
 		this.options = options
-		this.client = new OpenAI({
-			baseURL: "https://glama.ai/api/gateway/openai/v1",
-			apiKey: this.options.glamaApiKey,
-		})
+		const baseURL = "https://glama.ai/api/gateway/openai/v1"
+		const apiKey = this.options.glamaApiKey ?? "not-provided"
+		this.client = new OpenAI({ baseURL, apiKey })
 	}
 
 	async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {

+ 10 - 15
src/api/providers/openai-native.ts

@@ -17,9 +17,8 @@ export class OpenAiNativeHandler implements ApiHandler, SingleCompletionHandler
 
 	constructor(options: ApiHandlerOptions) {
 		this.options = options
-		this.client = new OpenAI({
-			apiKey: this.options.openAiNativeApiKey,
-		})
+		const apiKey = this.options.openAiNativeApiKey ?? "not-provided"
+		this.client = new OpenAI({ apiKey })
 	}
 
 	async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
@@ -41,7 +40,7 @@ export class OpenAiNativeHandler implements ApiHandler, SingleCompletionHandler
 	private async *handleO1FamilyMessage(
 		modelId: string,
 		systemPrompt: string,
-		messages: Anthropic.Messages.MessageParam[]
+		messages: Anthropic.Messages.MessageParam[],
 	): ApiStream {
 		// o1 supports developer prompt with formatting
 		// o1-preview and o1-mini only support user messages
@@ -63,7 +62,7 @@ export class OpenAiNativeHandler implements ApiHandler, SingleCompletionHandler
 	private async *handleO3FamilyMessage(
 		modelId: string,
 		systemPrompt: string,
-		messages: Anthropic.Messages.MessageParam[]
+		messages: Anthropic.Messages.MessageParam[],
 	): ApiStream {
 		const stream = await this.client.chat.completions.create({
 			model: "o3-mini",
@@ -85,7 +84,7 @@ export class OpenAiNativeHandler implements ApiHandler, SingleCompletionHandler
 	private async *handleDefaultModelMessage(
 		modelId: string,
 		systemPrompt: string,
-		messages: Anthropic.Messages.MessageParam[]
+		messages: Anthropic.Messages.MessageParam[],
 	): ApiStream {
 		const stream = await this.client.chat.completions.create({
 			model: modelId,
@@ -98,9 +97,7 @@ export class OpenAiNativeHandler implements ApiHandler, SingleCompletionHandler
 		yield* this.handleStreamResponse(stream)
 	}
 
-	private async *yieldResponseData(
-		response: OpenAI.Chat.Completions.ChatCompletion
-	): ApiStream {
+	private async *yieldResponseData(response: OpenAI.Chat.Completions.ChatCompletion): ApiStream {
 		yield {
 			type: "text",
 			text: response.choices[0]?.message.content || "",
@@ -112,9 +109,7 @@ export class OpenAiNativeHandler implements ApiHandler, SingleCompletionHandler
 		}
 	}
 
-	private async *handleStreamResponse(
-		stream: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>
-	): ApiStream {
+	private async *handleStreamResponse(stream: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>): ApiStream {
 		for await (const chunk of stream) {
 			const delta = chunk.choices[0]?.delta
 			if (delta?.content) {
@@ -168,7 +163,7 @@ export class OpenAiNativeHandler implements ApiHandler, SingleCompletionHandler
 
 	private getO1CompletionOptions(
 		modelId: string,
-		prompt: string
+		prompt: string,
 	): OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming {
 		return {
 			model: modelId,
@@ -178,7 +173,7 @@ export class OpenAiNativeHandler implements ApiHandler, SingleCompletionHandler
 
 	private getO3CompletionOptions(
 		modelId: string,
-		prompt: string
+		prompt: string,
 	): OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming {
 		return {
 			model: "o3-mini",
@@ -189,7 +184,7 @@ export class OpenAiNativeHandler implements ApiHandler, SingleCompletionHandler
 
 	private getDefaultCompletionOptions(
 		modelId: string,
-		prompt: string
+		prompt: string,
 	): OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming {
 		return {
 			model: modelId,

+ 5 - 6
src/api/providers/openai.ts

@@ -19,6 +19,8 @@ export class OpenAiHandler implements ApiHandler, SingleCompletionHandler {
 	constructor(options: ApiHandlerOptions) {
 		this.options = options
 
+		const baseURL = this.options.openAiBaseUrl ?? "https://api.openai.com/v1"
+		const apiKey = this.options.openAiApiKey ?? "not-provided"
 		let urlHost: string
 
 		try {
@@ -33,15 +35,12 @@ export class OpenAiHandler implements ApiHandler, SingleCompletionHandler {
 			// Azure API shape slightly differs from the core API shape:
 			// https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
 			this.client = new AzureOpenAI({
-				baseURL: this.options.openAiBaseUrl,
-				apiKey: this.options.openAiApiKey,
+				baseURL,
+				apiKey,
 				apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
 			})
 		} else {
-			this.client = new OpenAI({
-				baseURL: this.options.openAiBaseUrl,
-				apiKey: this.options.openAiApiKey,
-			})
+			this.client = new OpenAI({ baseURL, apiKey })
 		}
 	}
 

+ 10 - 8
src/api/providers/openrouter.ts

@@ -27,14 +27,16 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
 
 	constructor(options: ApiHandlerOptions) {
 		this.options = options
-		this.client = new OpenAI({
-			baseURL: this.options.openRouterBaseUrl || "https://openrouter.ai/api/v1",
-			apiKey: this.options.openRouterApiKey,
-			defaultHeaders: {
-				"HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline",
-				"X-Title": "Roo Code",
-			},
-		})
+
+		const baseURL = this.options.openRouterBaseUrl || "https://openrouter.ai/api/v1"
+		const apiKey = this.options.openRouterApiKey ?? "not-provided"
+
+		const defaultHeaders = {
+			"HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline",
+			"X-Title": "Roo Code",
+		}
+
+		this.client = new OpenAI({ baseURL, apiKey, defaultHeaders })
 	}
 
 	async *createMessage(

+ 3 - 4
src/api/providers/unbound.ts

@@ -16,10 +16,9 @@ export class UnboundHandler implements ApiHandler, SingleCompletionHandler {
 
 	constructor(options: ApiHandlerOptions) {
 		this.options = options
-		this.client = new OpenAI({
-			baseURL: "https://api.getunbound.ai/v1",
-			apiKey: this.options.unboundApiKey,
-		})
+		const baseURL = "https://api.getunbound.ai/v1"
+		const apiKey = this.options.unboundApiKey ?? "not-provided"
+		this.client = new OpenAI({ baseURL, apiKey })
 	}
 
 	async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {

+ 2 - 2
src/api/providers/vertex.ts

@@ -12,9 +12,9 @@ export class VertexHandler implements ApiHandler, SingleCompletionHandler {
 	constructor(options: ApiHandlerOptions) {
 		this.options = options
 		this.client = new AnthropicVertex({
-			projectId: this.options.vertexProjectId,
+			projectId: this.options.vertexProjectId ?? "not-provided",
 			// https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions
-			region: this.options.vertexRegion,
+			region: this.options.vertexRegion ?? "us-east5",
 		})
 	}
 

+ 6 - 4
src/core/Cline.ts

@@ -832,7 +832,7 @@ export class Cline {
 		this.lastApiRequestTime = Date.now()
 
 		if (mcpEnabled ?? true) {
-			mcpHub = this.providerRef.deref()?.mcpHub
+			mcpHub = this.providerRef.deref()?.getMcpHub()
 			if (!mcpHub) {
 				throw new Error("MCP hub not available")
 			}
@@ -1013,7 +1013,7 @@ export class Cline {
 					// (have to do this for partial and complete since sending content in thinking tags to markdown renderer will automatically be removed)
 					// Remove end substrings of <thinking or </thinking (below xml parsing is only for opening tags)
 					// (this is done with the xml parsing below now, but keeping here for reference)
-					// content = content.replace(/<\/?t(?:h(?:i(?:n(?:k(?:i(?:n(?:g)?)?)?)?$/, "")
+					// content = content.replace(/<\/?t(?:h(?:i(?:n(?:k(?:i(?:n(?:g)?)?)?$/, "")
 					// Remove all instances of <thinking> (with optional line break after) and </thinking> (with optional line break before)
 					// - Needs to be separate since we dont want to remove the line break before the first tag
 					// - Needs to happen before the xml parsing below
@@ -2267,7 +2267,8 @@ export class Cline {
 								await this.say("mcp_server_request_started") // same as browser_action_result
 								const toolResult = await this.providerRef
 									.deref()
-									?.mcpHub?.callTool(server_name, tool_name, parsedArguments)
+									?.getMcpHub()
+									?.callTool(server_name, tool_name, parsedArguments)
 
 								// TODO: add progress indicator and ability to parse images and non-text responses
 								const toolResultPretty =
@@ -2335,7 +2336,8 @@ export class Cline {
 								await this.say("mcp_server_request_started")
 								const resourceResult = await this.providerRef
 									.deref()
-									?.mcpHub?.readResource(server_name, uri)
+									?.getMcpHub()
+									?.readResource(server_name, uri)
 								const resourceResultPretty =
 									resourceResult?.contents
 										.map((item) => {

+ 29 - 2
src/core/webview/ClineProvider.ts

@@ -36,6 +36,7 @@ import { EXPERIMENT_IDS, experiments as Experiments, experimentDefault, Experime
 import { CustomSupportPrompts, supportPrompt } from "../../shared/support-prompt"
 
 import { ACTION_NAMES } from "../CodeActionProvider"
+import { McpServerManager } from "../../services/mcp/McpServerManager"
 
 /*
 https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -138,7 +139,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 	private isViewLaunched = false
 	private cline?: Cline
 	private workspaceTracker?: WorkspaceTracker
-	mcpHub?: McpHub
+	protected mcpHub?: McpHub // Change from private to protected
 	private latestAnnouncementId = "jan-21-2025-custom-modes" // update to some unique identifier when we add a new announcement
 	configManager: ConfigManager
 	customModesManager: CustomModesManager
@@ -150,11 +151,19 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 		this.outputChannel.appendLine("ClineProvider instantiated")
 		ClineProvider.activeInstances.add(this)
 		this.workspaceTracker = new WorkspaceTracker(this)
-		this.mcpHub = new McpHub(this)
 		this.configManager = new ConfigManager(this.context)
 		this.customModesManager = new CustomModesManager(this.context, async () => {
 			await this.postStateToWebview()
 		})
+
+		// Initialize MCP Hub through the singleton manager
+		McpServerManager.getInstance(this.context, this)
+			.then((hub) => {
+				this.mcpHub = hub
+			})
+			.catch((error) => {
+				this.outputChannel.appendLine(`Failed to initialize MCP Hub: ${error}`)
+			})
 	}
 
 	/*
@@ -183,6 +192,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 		this.customModesManager?.dispose()
 		this.outputChannel.appendLine("Disposed all disposables")
 		ClineProvider.activeInstances.delete(this)
+
+		// Unregister from McpServerManager
+		McpServerManager.unregisterProvider(this)
 	}
 
 	public static getVisibleInstance(): ClineProvider | undefined {
@@ -603,6 +615,15 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 								this.postMessageToWebview({ type: "openRouterModels", openRouterModels })
 							}
 						})
+
+						// If MCP Hub is already initialized, update the webview with current server list
+						if (this.mcpHub) {
+							this.postMessageToWebview({
+								type: "mcpServers",
+								mcpServers: this.mcpHub.getServers(),
+							})
+						}
+
 						// gui relies on model info to be up-to-date to provide the most accurate pricing, so we need to fetch the latest details on launch.
 						// we do this for all users since many users switch between api providers and if they were to switch back to openrouter it would be showing outdated model info if we hadn't retrieved the latest at this point
 						// (see normalizeApiConfiguration > openrouter)
@@ -2174,6 +2195,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			autoApprovalEnabled: autoApprovalEnabled ?? false,
 			customModes: await this.customModesManager.getCustomModes(),
 			experiments: experiments ?? experimentDefault,
+			mcpServers: this.mcpHub?.getServers() ?? [],
 		}
 	}
 
@@ -2612,4 +2634,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 	get messages() {
 		return this.cline?.clineMessages || []
 	}
+
+	// Add public getter
+	public getMcpHub(): McpHub | undefined {
+		return this.mcpHub
+	}
 }

+ 7 - 2
src/extension.ts

@@ -6,6 +6,7 @@ import "./utils/path" // Necessary to have access to String.prototype.toPosix.
 import { CodeActionProvider } from "./core/CodeActionProvider"
 import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
 import { handleUri, registerCommands, registerCodeActions, registerTerminalActions } from "./activate"
+import { McpServerManager } from "./services/mcp/McpServerManager"
 
 /**
  * Built using https://github.com/microsoft/vscode-webview-ui-toolkit
@@ -16,10 +17,12 @@ import { handleUri, registerCommands, registerCodeActions, registerTerminalActio
  */
 
 let outputChannel: vscode.OutputChannel
+let extensionContext: vscode.ExtensionContext
 
 // This method is called when your extension is activated.
 // Your extension is activated the very first time the command is executed.
 export function activate(context: vscode.ExtensionContext) {
+	extensionContext = context
 	outputChannel = vscode.window.createOutputChannel("Roo-Code")
 	context.subscriptions.push(outputChannel)
 	outputChannel.appendLine("Roo-Code extension activated")
@@ -83,7 +86,9 @@ export function activate(context: vscode.ExtensionContext) {
 	return createClineAPI(outputChannel, sidebarProvider)
 }
 
-// This method is called when your extension is deactivated.
-export function deactivate() {
+// This method is called when your extension is deactivated
+export async function deactivate() {
 	outputChannel.appendLine("Roo-Code extension deactivated")
+	// Clean up MCP server manager
+	await McpServerManager.cleanup(extensionContext)
 }

+ 317 - 0
src/services/checkpoints/CheckpointService.ts

@@ -0,0 +1,317 @@
+import fs from "fs/promises"
+import { existsSync } from "fs"
+import path from "path"
+
+import debug from "debug"
+import simpleGit, { SimpleGit, CleanOptions } from "simple-git"
+
+if (process.env.NODE_ENV !== "test") {
+	debug.enable("simple-git")
+}
+
+export interface Checkpoint {
+	hash: string
+	message: string
+	timestamp?: Date
+}
+
+export type CheckpointServiceOptions = {
+	taskId: string
+	git?: SimpleGit
+	baseDir: string
+	log?: (message: string) => void
+}
+
+/**
+ * The CheckpointService provides a mechanism for storing a snapshot of the
+ * current VSCode workspace each time a Roo Code tool is executed. It uses Git
+ * under the hood.
+ *
+ * HOW IT WORKS
+ *
+ * Two branches are used:
+ *  - A main branch for normal operation (the branch you are currently on).
+ *  - A hidden branch for storing checkpoints.
+ *
+ * Saving a checkpoint:
+ *  - Current changes are stashed (including untracked files).
+ *  - The hidden branch is reset to match main.
+ *  - Stashed changes are applied and committed as a checkpoint on the hidden
+ *    branch.
+ *  - We return to the main branch with the original state restored.
+ *
+ * Restoring a checkpoint:
+ *  - The workspace is restored to the state of the specified checkpoint using
+ *    `git restore` and `git clean`.
+ *
+ * This approach allows for:
+ *  - Non-destructive version control (main branch remains untouched).
+ *  - Preservation of the full history of checkpoints.
+ *  - Safe restoration to any previous checkpoint.
+ *
+ * NOTES
+ *
+ *  - Git must be installed.
+ *  - If the current working directory is not a Git repository, we will
+ *    initialize a new one with a .gitkeep file.
+ *  - If you manually edit files and then restore a checkpoint, the changes
+ *    will be lost. Addressing this adds some complexity to the implementation
+ *    and it's not clear whether it's worth it.
+ */
+
+export class CheckpointService {
+	constructor(
+		public readonly taskId: string,
+		private readonly git: SimpleGit,
+		public readonly baseDir: string,
+		public readonly mainBranch: string,
+		public readonly baseCommitHash: string,
+		public readonly hiddenBranch: string,
+		private readonly log: (message: string) => void,
+	) {}
+
+	private async pushStash() {
+		const status = await this.git.status()
+
+		if (status.files.length > 0) {
+			await this.git.stash(["-u"]) // Includes tracked and untracked files.
+			return true
+		}
+
+		return false
+	}
+
+	private async applyStash() {
+		const stashList = await this.git.stashList()
+
+		if (stashList.all.length > 0) {
+			await this.git.stash(["apply"]) // Applies the most recent stash only.
+			return true
+		}
+
+		return false
+	}
+
+	private async popStash() {
+		const stashList = await this.git.stashList()
+
+		if (stashList.all.length > 0) {
+			await this.git.stash(["pop", "--index"]) // Pops the most recent stash only.
+			return true
+		}
+
+		return false
+	}
+
+	private async ensureBranch(expectedBranch: string) {
+		const branch = await this.git.revparse(["--abbrev-ref", "HEAD"])
+
+		if (branch.trim() !== expectedBranch) {
+			throw new Error(`Git branch mismatch: expected '${expectedBranch}' but found '${branch}'`)
+		}
+	}
+
+	public async getDiff({ from, to }: { from?: string; to: string }) {
+		const result = []
+
+		if (!from) {
+			from = this.baseCommitHash
+		}
+
+		const { files } = await this.git.diffSummary([`${from}..${to}`])
+
+		for (const file of files.filter((f) => !f.binary)) {
+			const relPath = file.file
+			const absPath = path.join(this.baseDir, relPath)
+
+			// If modified both before and after will generate content.
+			// If added only after will generate content.
+			// If deleted only before will generate content.
+			let beforeContent = ""
+			let afterContent = ""
+
+			try {
+				beforeContent = await this.git.show([`${from}:${relPath}`])
+			} catch (err) {
+				// File doesn't exist in older commit.
+			}
+
+			try {
+				afterContent = await this.git.show([`${to}:${relPath}`])
+			} catch (err) {
+				// File doesn't exist in newer commit.
+			}
+
+			result.push({
+				paths: { relative: relPath, absolute: absPath },
+				content: { before: beforeContent, after: afterContent },
+			})
+		}
+
+		return result
+	}
+
+	public async saveCheckpoint(message: string) {
+		await this.ensureBranch(this.mainBranch)
+
+		// Attempt to stash pending changes (including untracked files).
+		const pendingChanges = await this.pushStash()
+
+		// Get the latest commit on the hidden branch before we reset it.
+		const latestHash = await this.git.revparse([this.hiddenBranch])
+
+		// Check if there is any diff relative to the latest commit.
+		if (!pendingChanges) {
+			const diff = await this.git.diff([latestHash])
+
+			if (!diff) {
+				this.log(`[saveCheckpoint] No changes detected, giving up`)
+				return undefined
+			}
+		}
+
+		await this.git.checkout(this.hiddenBranch)
+
+		const reset = async () => {
+			await this.git.reset(["HEAD", "."])
+			await this.git.clean([CleanOptions.FORCE, CleanOptions.RECURSIVE])
+			await this.git.reset(["--hard", latestHash])
+			await this.git.checkout(this.mainBranch)
+			await this.popStash()
+		}
+
+		try {
+			// Reset hidden branch to match main and apply the pending changes.
+			await this.git.reset(["--hard", this.mainBranch])
+
+			if (pendingChanges) {
+				await this.applyStash()
+			}
+
+			// Using "-A" ensures that deletions are staged as well.
+			await this.git.add(["-A"])
+			const diff = await this.git.diff([latestHash])
+
+			if (!diff) {
+				this.log(`[saveCheckpoint] No changes detected, resetting and giving up`)
+				await reset()
+				return undefined
+			}
+
+			// Otherwise, commit the changes.
+			const status = await this.git.status()
+			this.log(`[saveCheckpoint] Changes detected, committing ${JSON.stringify(status)}`)
+
+			// Allow empty commits in order to correctly handle deletion of
+			// untracked files (see unit tests for an example of this).
+			// Additionally, skip pre-commit hooks so that they don't slow
+			// things down or tamper with the contents of the commit.
+			const commit = await this.git.commit(message, undefined, {
+				"--allow-empty": null,
+				"--no-verify": null,
+			})
+
+			await this.git.checkout(this.mainBranch)
+
+			if (pendingChanges) {
+				await this.popStash()
+			}
+
+			return commit
+		} catch (err) {
+			this.log(`[saveCheckpoint] Failed to save checkpoint: ${err instanceof Error ? err.message : String(err)}`)
+
+			// If we're not on the main branch then we need to trigger a reset
+			// to return to the main branch and restore it's previous state.
+			const currentBranch = await this.git.revparse(["--abbrev-ref", "HEAD"])
+
+			if (currentBranch.trim() !== this.mainBranch) {
+				await reset()
+			}
+
+			throw err
+		}
+	}
+
+	public async restoreCheckpoint(commitHash: string) {
+		await this.ensureBranch(this.mainBranch)
+		await this.git.clean([CleanOptions.FORCE, CleanOptions.RECURSIVE])
+		await this.git.raw(["restore", "--source", commitHash, "--worktree", "--", "."])
+	}
+
+	public static async create({ taskId, git, baseDir, log = console.log }: CheckpointServiceOptions) {
+		git =
+			git ||
+			simpleGit({
+				baseDir,
+				binary: "git",
+				maxConcurrentProcesses: 1,
+				config: [],
+				trimmed: true,
+			})
+
+		const version = await git.version()
+
+		if (!version?.installed) {
+			throw new Error(`Git is not installed. Please install Git if you wish to use checkpoints.`)
+		}
+
+		if (!baseDir || !existsSync(baseDir)) {
+			throw new Error(`Base directory is not set or does not exist.`)
+		}
+
+		const { currentBranch, currentSha, hiddenBranch } = await CheckpointService.initRepo({
+			taskId,
+			git,
+			baseDir,
+			log,
+		})
+
+		log(
+			`[CheckpointService] taskId = ${taskId}, baseDir = ${baseDir}, currentBranch = ${currentBranch}, currentSha = ${currentSha}, hiddenBranch = ${hiddenBranch}`,
+		)
+		return new CheckpointService(taskId, git, baseDir, currentBranch, currentSha, hiddenBranch, log)
+	}
+
+	private static async initRepo({ taskId, git, baseDir, log }: Required<CheckpointServiceOptions>) {
+		const isExistingRepo = existsSync(path.join(baseDir, ".git"))
+
+		if (!isExistingRepo) {
+			await git.init()
+			log(`[initRepo] Initialized new Git repository at ${baseDir}`)
+		}
+
+		await git.addConfig("user.name", "Roo Code")
+		await git.addConfig("user.email", "[email protected]")
+
+		if (!isExistingRepo) {
+			// We need at least one file to commit, otherwise the initial
+			// commit will fail, unless we use the `--allow-empty` flag.
+			// However, using an empty commit causes problems when restoring
+			// the checkpoint (i.e. the `git restore` command doesn't work
+			// for empty commits).
+			await fs.writeFile(path.join(baseDir, ".gitkeep"), "")
+			await git.add(".")
+			const commit = await git.commit("Initial commit")
+
+			if (!commit.commit) {
+				throw new Error("Failed to create initial commit")
+			}
+
+			log(`[initRepo] Initial commit: ${commit.commit}`)
+		}
+
+		const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
+		const currentSha = await git.revparse(["HEAD"])
+
+		const hiddenBranch = `roo-code-checkpoints-${taskId}`
+		const branchSummary = await git.branch()
+
+		if (!branchSummary.all.includes(hiddenBranch)) {
+			await git.checkoutBranch(hiddenBranch, currentBranch) // git checkout -b <hiddenBranch> <currentBranch>
+			await git.checkout(currentBranch) // git checkout <currentBranch>
+		}
+
+		return { currentBranch, currentSha, hiddenBranch }
+	}
+}

+ 337 - 0
src/services/checkpoints/__tests__/CheckpointService.test.ts

@@ -0,0 +1,337 @@
+// npx jest src/services/checkpoints/__tests__/CheckpointService.test.ts
+
+import fs from "fs/promises"
+import path from "path"
+import os from "os"
+
+import { simpleGit, SimpleGit } from "simple-git"
+
+import { CheckpointService } from "../CheckpointService"
+
+describe("CheckpointService", () => {
+	const taskId = "test-task"
+	let git: SimpleGit
+	let testFile: string
+	let service: CheckpointService
+
+	beforeEach(async () => {
+		// Create a temporary directory for testing.
+		const baseDir = path.join(os.tmpdir(), `checkpoint-service-test-${Date.now()}`)
+		await fs.mkdir(baseDir)
+
+		// Initialize git repo.
+		git = simpleGit(baseDir)
+		await git.init()
+		await git.addConfig("user.name", "Roo Code")
+		await git.addConfig("user.email", "[email protected]")
+
+		// Create test file.
+		testFile = path.join(baseDir, "test.txt")
+		await fs.writeFile(testFile, "Hello, world!")
+
+		// Create initial commit.
+		await git.add(".")
+		await git.commit("Initial commit")!
+
+		// Create service instance.
+		const log = () => {}
+		service = await CheckpointService.create({ taskId, git, baseDir, log })
+	})
+
+	afterEach(async () => {
+		await fs.rm(service.baseDir, { recursive: true, force: true })
+		jest.restoreAllMocks()
+	})
+
+	describe("getDiff", () => {
+		it("returns the correct diff between commits", async () => {
+			await fs.writeFile(testFile, "Ahoy, world!")
+			const commit1 = await service.saveCheckpoint("First checkpoint")
+			expect(commit1?.commit).toBeTruthy()
+
+			await fs.writeFile(testFile, "Goodbye, world!")
+			const commit2 = await service.saveCheckpoint("Second checkpoint")
+			expect(commit2?.commit).toBeTruthy()
+
+			const diff1 = await service.getDiff({ to: commit1!.commit })
+			expect(diff1).toHaveLength(1)
+			expect(diff1[0].paths.relative).toBe("test.txt")
+			expect(diff1[0].paths.absolute).toBe(testFile)
+			expect(diff1[0].content.before).toBe("Hello, world!")
+			expect(diff1[0].content.after).toBe("Ahoy, world!")
+
+			const diff2 = await service.getDiff({ to: commit2!.commit })
+			expect(diff2).toHaveLength(1)
+			expect(diff2[0].paths.relative).toBe("test.txt")
+			expect(diff2[0].paths.absolute).toBe(testFile)
+			expect(diff2[0].content.before).toBe("Hello, world!")
+			expect(diff2[0].content.after).toBe("Goodbye, world!")
+
+			const diff12 = await service.getDiff({ from: commit1!.commit, to: commit2!.commit })
+			expect(diff12).toHaveLength(1)
+			expect(diff12[0].paths.relative).toBe("test.txt")
+			expect(diff12[0].paths.absolute).toBe(testFile)
+			expect(diff12[0].content.before).toBe("Ahoy, world!")
+			expect(diff12[0].content.after).toBe("Goodbye, world!")
+		})
+
+		it("handles new files in diff", async () => {
+			const newFile = path.join(service.baseDir, "new.txt")
+			await fs.writeFile(newFile, "New file content")
+			const commit = await service.saveCheckpoint("Add new file")
+			expect(commit?.commit).toBeTruthy()
+
+			const changes = await service.getDiff({ to: commit!.commit })
+			const change = changes.find((c) => c.paths.relative === "new.txt")
+			expect(change).toBeDefined()
+			expect(change?.content.before).toBe("")
+			expect(change?.content.after).toBe("New file content")
+		})
+
+		it("handles deleted files in diff", async () => {
+			const fileToDelete = path.join(service.baseDir, "new.txt")
+			await fs.writeFile(fileToDelete, "New file content")
+			const commit1 = await service.saveCheckpoint("Add file")
+			expect(commit1?.commit).toBeTruthy()
+
+			await fs.unlink(fileToDelete)
+			const commit2 = await service.saveCheckpoint("Delete file")
+			expect(commit2?.commit).toBeTruthy()
+
+			const changes = await service.getDiff({ from: commit1!.commit, to: commit2!.commit })
+			const change = changes.find((c) => c.paths.relative === "new.txt")
+			expect(change).toBeDefined()
+			expect(change!.content.before).toBe("New file content")
+			expect(change!.content.after).toBe("")
+		})
+	})
+
+	describe("saveCheckpoint", () => {
+		it("creates a checkpoint if there are pending changes", async () => {
+			await fs.writeFile(testFile, "Ahoy, world!")
+			const commit1 = await service.saveCheckpoint("First checkpoint")
+			expect(commit1?.commit).toBeTruthy()
+			const details1 = await git.show([commit1!.commit])
+			expect(details1).toContain("-Hello, world!")
+			expect(details1).toContain("+Ahoy, world!")
+
+			await fs.writeFile(testFile, "Hola, world!")
+			const commit2 = await service.saveCheckpoint("Second checkpoint")
+			expect(commit2?.commit).toBeTruthy()
+			const details2 = await git.show([commit2!.commit])
+			expect(details2).toContain("-Hello, world!")
+			expect(details2).toContain("+Hola, world!")
+
+			// Switch to checkpoint 1.
+			await service.restoreCheckpoint(commit1!.commit)
+			expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!")
+
+			// Switch to checkpoint 2.
+			await service.restoreCheckpoint(commit2!.commit)
+			expect(await fs.readFile(testFile, "utf-8")).toBe("Hola, world!")
+
+			// Switch back to initial commit.
+			await service.restoreCheckpoint(service.baseCommitHash)
+			expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!")
+		})
+
+		it("preserves workspace and index state after saving checkpoint", async () => {
+			// Create three files with different states: staged, unstaged, and mixed.
+			const unstagedFile = path.join(service.baseDir, "unstaged.txt")
+			const stagedFile = path.join(service.baseDir, "staged.txt")
+			const mixedFile = path.join(service.baseDir, "mixed.txt")
+
+			await fs.writeFile(unstagedFile, "Initial unstaged")
+			await fs.writeFile(stagedFile, "Initial staged")
+			await fs.writeFile(mixedFile, "Initial mixed")
+			await git.add(["."])
+			const result = await git.commit("Add initial files")
+			expect(result?.commit).toBeTruthy()
+
+			await fs.writeFile(unstagedFile, "Modified unstaged")
+
+			await fs.writeFile(stagedFile, "Modified staged")
+			await git.add([stagedFile])
+
+			await fs.writeFile(mixedFile, "Modified mixed - staged")
+			await git.add([mixedFile])
+			await fs.writeFile(mixedFile, "Modified mixed - unstaged")
+
+			// Save checkpoint.
+			const commit = await service.saveCheckpoint("Test checkpoint")
+			expect(commit?.commit).toBeTruthy()
+
+			// Verify workspace state is preserved.
+			const status = await git.status()
+
+			// All files should be modified.
+			expect(status.modified).toContain("unstaged.txt")
+			expect(status.modified).toContain("staged.txt")
+			expect(status.modified).toContain("mixed.txt")
+
+			// Only staged and mixed files should be staged.
+			expect(status.staged).not.toContain("unstaged.txt")
+			expect(status.staged).toContain("staged.txt")
+			expect(status.staged).toContain("mixed.txt")
+
+			// Verify file contents.
+			expect(await fs.readFile(unstagedFile, "utf-8")).toBe("Modified unstaged")
+			expect(await fs.readFile(stagedFile, "utf-8")).toBe("Modified staged")
+			expect(await fs.readFile(mixedFile, "utf-8")).toBe("Modified mixed - unstaged")
+
+			// Verify staged changes (--cached shows only staged changes).
+			const stagedDiff = await git.diff(["--cached", "mixed.txt"])
+			expect(stagedDiff).toContain("-Initial mixed")
+			expect(stagedDiff).toContain("+Modified mixed - staged")
+
+			// Verify unstaged changes (shows working directory changes).
+			const unstagedDiff = await git.diff(["mixed.txt"])
+			expect(unstagedDiff).toContain("-Modified mixed - staged")
+			expect(unstagedDiff).toContain("+Modified mixed - unstaged")
+		})
+
+		it("does not create a checkpoint if there are no pending changes", async () => {
+			await fs.writeFile(testFile, "Ahoy, world!")
+			const commit = await service.saveCheckpoint("First checkpoint")
+			expect(commit?.commit).toBeTruthy()
+
+			const commit2 = await service.saveCheckpoint("Second checkpoint")
+			expect(commit2?.commit).toBeFalsy()
+		})
+
+		it("includes untracked files in checkpoints", async () => {
+			// Create an untracked file.
+			const untrackedFile = path.join(service.baseDir, "untracked.txt")
+			await fs.writeFile(untrackedFile, "I am untracked!")
+
+			// Save a checkpoint with the untracked file.
+			const commit1 = await service.saveCheckpoint("Checkpoint with untracked file")
+			expect(commit1?.commit).toBeTruthy()
+
+			// Verify the untracked file was included in the checkpoint.
+			const details = await git.show([commit1!.commit])
+			expect(details).toContain("+I am untracked!")
+
+			// Create another checkpoint with a different state.
+			await fs.writeFile(testFile, "Changed tracked file")
+			const commit2 = await service.saveCheckpoint("Second checkpoint")
+			expect(commit2?.commit).toBeTruthy()
+
+			// Restore first checkpoint and verify untracked file is preserved.
+			await service.restoreCheckpoint(commit1!.commit)
+			expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!")
+			expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!")
+
+			// Restore second checkpoint and verify untracked file remains (since
+			// restore preserves untracked files)
+			await service.restoreCheckpoint(commit2!.commit)
+			expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!")
+			expect(await fs.readFile(testFile, "utf-8")).toBe("Changed tracked file")
+		})
+
+		it("throws if we're on the wrong branch", async () => {
+			// Create and switch to a feature branch.
+			await git.checkoutBranch("feature", service.mainBranch)
+
+			// Attempt to save checkpoint from feature branch.
+			await expect(service.saveCheckpoint("test")).rejects.toThrow(
+				`Git branch mismatch: expected '${service.mainBranch}' but found 'feature'`,
+			)
+
+			// Attempt to restore checkpoint from feature branch.
+			await expect(service.restoreCheckpoint(service.baseCommitHash)).rejects.toThrow(
+				`Git branch mismatch: expected '${service.mainBranch}' but found 'feature'`,
+			)
+		})
+
+		it("cleans up staged files if a commit fails", async () => {
+			await fs.writeFile(testFile, "Changed content")
+
+			// Mock git commit to simulate failure.
+			jest.spyOn(git, "commit").mockRejectedValue(new Error("Simulated commit failure"))
+
+			// Attempt to save checkpoint.
+			await expect(service.saveCheckpoint("test")).rejects.toThrow("Simulated commit failure")
+
+			// Verify files are unstaged.
+			const status = await git.status()
+			expect(status.staged).toHaveLength(0)
+		})
+
+		it("handles file deletions correctly", async () => {
+			await fs.writeFile(testFile, "I am tracked!")
+			const untrackedFile = path.join(service.baseDir, "new.txt")
+			await fs.writeFile(untrackedFile, "I am untracked!")
+			const commit1 = await service.saveCheckpoint("First checkpoint")
+			expect(commit1?.commit).toBeTruthy()
+
+			await fs.unlink(testFile)
+			await fs.unlink(untrackedFile)
+			const commit2 = await service.saveCheckpoint("Second checkpoint")
+			expect(commit2?.commit).toBeTruthy()
+
+			// Verify files are gone.
+			await expect(fs.readFile(testFile, "utf-8")).rejects.toThrow()
+			await expect(fs.readFile(untrackedFile, "utf-8")).rejects.toThrow()
+
+			// Restore first checkpoint.
+			await service.restoreCheckpoint(commit1!.commit)
+			expect(await fs.readFile(testFile, "utf-8")).toBe("I am tracked!")
+			expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!")
+
+			// Restore second checkpoint.
+			await service.restoreCheckpoint(commit2!.commit)
+			await expect(fs.readFile(testFile, "utf-8")).rejects.toThrow()
+			await expect(fs.readFile(untrackedFile, "utf-8")).rejects.toThrow()
+		})
+	})
+
+	describe("create", () => {
+		it("initializes a git repository if one does not already exist", async () => {
+			const baseDir = path.join(os.tmpdir(), `checkpoint-service-test2-${Date.now()}`)
+			await fs.mkdir(baseDir)
+			const newTestFile = path.join(baseDir, "test.txt")
+
+			const newGit = simpleGit(baseDir)
+			const initSpy = jest.spyOn(newGit, "init")
+			const newService = await CheckpointService.create({ taskId, git: newGit, baseDir, log: () => {} })
+
+			// Ensure the git repository was initialized.
+			expect(initSpy).toHaveBeenCalled()
+
+			// Save a checkpoint: Hello, world!
+			await fs.writeFile(newTestFile, "Hello, world!")
+			const commit1 = await newService.saveCheckpoint("Hello, world!")
+			expect(commit1?.commit).toBeTruthy()
+			expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
+
+			// Restore initial commit; the file should no longer exist.
+			await newService.restoreCheckpoint(newService.baseCommitHash)
+			await expect(fs.access(newTestFile)).rejects.toThrow()
+
+			// Restore to checkpoint 1; the file should now exist.
+			await newService.restoreCheckpoint(commit1!.commit)
+			expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
+
+			// Save a new checkpoint: Ahoy, world!
+			await fs.writeFile(newTestFile, "Ahoy, world!")
+			const commit2 = await newService.saveCheckpoint("Ahoy, world!")
+			expect(commit2?.commit).toBeTruthy()
+			expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!")
+
+			// Restore "Hello, world!"
+			await newService.restoreCheckpoint(commit1!.commit)
+			expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
+
+			// Restore "Ahoy, world!"
+			await newService.restoreCheckpoint(commit2!.commit)
+			expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!")
+
+			// Restore initial commit.
+			await newService.restoreCheckpoint(newService.baseCommitHash)
+			await expect(fs.access(newTestFile)).rejects.toThrow()
+
+			await fs.rm(newService.baseDir, { recursive: true, force: true })
+		})
+	})
+})

+ 83 - 0
src/services/mcp/McpServerManager.ts

@@ -0,0 +1,83 @@
+import * as vscode from "vscode"
+import { McpHub } from "./McpHub"
+import { ClineProvider } from "../../core/webview/ClineProvider"
+
+/**
+ * Singleton manager for MCP server instances.
+ * Ensures only one set of MCP servers runs across all webviews.
+ */
+export class McpServerManager {
+	private static instance: McpHub | null = null
+	private static readonly GLOBAL_STATE_KEY = "mcpHubInstanceId"
+	private static providers: Set<ClineProvider> = new Set()
+	private static initializationPromise: Promise<McpHub> | null = null
+
+	/**
+	 * Get the singleton McpHub instance.
+	 * Creates a new instance if one doesn't exist.
+	 * Thread-safe implementation using a promise-based lock.
+	 */
+	static async getInstance(context: vscode.ExtensionContext, provider: ClineProvider): Promise<McpHub> {
+		// Register the provider
+		this.providers.add(provider)
+
+		// If we already have an instance, return it
+		if (this.instance) {
+			return this.instance
+		}
+
+		// If initialization is in progress, wait for it
+		if (this.initializationPromise) {
+			return this.initializationPromise
+		}
+
+		// Create a new initialization promise
+		this.initializationPromise = (async () => {
+			try {
+				// Double-check instance in case it was created while we were waiting
+				if (!this.instance) {
+					this.instance = new McpHub(provider)
+					// Store a unique identifier in global state to track the primary instance
+					await context.globalState.update(this.GLOBAL_STATE_KEY, Date.now().toString())
+				}
+				return this.instance
+			} finally {
+				// Clear the initialization promise after completion or error
+				this.initializationPromise = null
+			}
+		})()
+
+		return this.initializationPromise
+	}
+
+	/**
+	 * Remove a provider from the tracked set.
+	 * This is called when a webview is disposed.
+	 */
+	static unregisterProvider(provider: ClineProvider): void {
+		this.providers.delete(provider)
+	}
+
+	/**
+	 * Notify all registered providers of server state changes.
+	 */
+	static notifyProviders(message: any): void {
+		this.providers.forEach((provider) => {
+			provider.postMessageToWebview(message).catch((error) => {
+				console.error("Failed to notify provider:", error)
+			})
+		})
+	}
+
+	/**
+	 * Clean up the singleton instance and all its resources.
+	 */
+	static async cleanup(context: vscode.ExtensionContext): Promise<void> {
+		if (this.instance) {
+			await this.instance.dispose()
+			this.instance = null
+			await context.globalState.update(this.GLOBAL_STATE_KEY, undefined)
+		}
+		this.providers.clear()
+	}
+}

+ 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",

+ 12 - 9
webview-ui/src/components/chat/ChatRow.tsx

@@ -2,6 +2,7 @@ import { VSCodeBadge, VSCodeButton, VSCodeProgressRing } from "@vscode/webview-u
 import deepEqual from "fast-deep-equal"
 import React, { memo, useEffect, useMemo, useRef, useState } from "react"
 import { useSize } from "react-use"
+import { useCopyToClipboard } from "../../utils/clipboard"
 import {
 	ClineApiReqInfo,
 	ClineAskUseMcpServer,
@@ -985,6 +986,7 @@ export const ProgressIndicator = () => (
 
 const Markdown = memo(({ markdown, partial }: { markdown?: string; partial?: boolean }) => {
 	const [isHovering, setIsHovering] = useState(false)
+	const { copyWithFeedback } = useCopyToClipboard(200) // shorter feedback duration for copy button flash
 
 	return (
 		<div
@@ -1021,15 +1023,16 @@ const Markdown = memo(({ markdown, partial }: { markdown?: string; partial?: boo
 							background: "var(--vscode-editor-background)",
 							transition: "background 0.2s ease-in-out",
 						}}
-						onClick={() => {
-							navigator.clipboard.writeText(markdown)
-							// Flash the button background briefly to indicate success
-							const button = document.activeElement as HTMLElement
-							if (button) {
-								button.style.background = "var(--vscode-button-background)"
-								setTimeout(() => {
-									button.style.background = ""
-								}, 200)
+						onClick={async () => {
+							const success = await copyWithFeedback(markdown)
+							if (success) {
+								const button = document.activeElement as HTMLElement
+								if (button) {
+									button.style.background = "var(--vscode-button-background)"
+									setTimeout(() => {
+										button.style.background = ""
+									}, 200)
+								}
 							}
 						}}
 						title="Copy as markdown">

+ 40 - 2
webview-ui/src/components/history/HistoryPreview.tsx

@@ -3,6 +3,7 @@ import { useExtensionState } from "../../context/ExtensionStateContext"
 import { vscode } from "../../utils/vscode"
 import { memo } from "react"
 import { formatLargeNumber } from "../../utils/format"
+import { useCopyToClipboard } from "../../utils/clipboard"
 
 type HistoryPreviewProps = {
 	showHistoryView: () => void
@@ -10,6 +11,7 @@ type HistoryPreviewProps = {
 
 const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
 	const { taskHistory } = useExtensionState()
+	const { showCopyFeedback, copyWithFeedback } = useCopyToClipboard()
 	const handleHistorySelect = (id: string) => {
 		vscode.postMessage({ type: "showTaskWithId", text: id })
 	}
@@ -31,8 +33,30 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
 
 	return (
 		<div style={{ flexShrink: 0 }}>
+			{showCopyFeedback && <div className="copy-modal">Prompt Copied to Clipboard</div>}
 			<style>
 				{`
+					.copy-modal {
+						position: fixed;
+						top: 50%;
+						left: 50%;
+						transform: translate(-50%, -50%);
+						background-color: var(--vscode-notifications-background);
+						color: var(--vscode-notifications-foreground);
+						padding: 12px 20px;
+						border-radius: 4px;
+						box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+						z-index: 1000;
+						transition: opacity 0.2s ease-in-out;
+					}
+					.copy-button {
+						opacity: 0;
+						pointer-events: none;
+					}
+					.history-preview-item:hover .copy-button {
+						opacity: 1;
+						pointer-events: auto;
+					}
 					.history-preview-item {
 						background-color: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 65%, transparent);
 						border-radius: 4px;
@@ -79,8 +103,14 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
 							key={item.id}
 							className="history-preview-item"
 							onClick={() => handleHistorySelect(item.id)}>
-							<div style={{ padding: "12px" }}>
-								<div style={{ marginBottom: "8px" }}>
+							<div style={{ padding: "12px", position: "relative" }}>
+								<div
+									style={{
+										marginBottom: "8px",
+										display: "flex",
+										justifyContent: "space-between",
+										alignItems: "center",
+									}}>
 									<span
 										style={{
 											color: "var(--vscode-descriptionForeground)",
@@ -90,6 +120,14 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
 										}}>
 										{formatDate(item.ts)}
 									</span>
+									<button
+										title="Copy Prompt"
+										aria-label="Copy Prompt"
+										className="copy-button"
+										data-appearance="icon"
+										onClick={(e) => copyWithFeedback(item.task, e)}>
+										<span className="codicon codicon-copy"></span>
+									</button>
 								</div>
 								<div
 									style={{

+ 4 - 14
webview-ui/src/components/history/HistoryView.tsx

@@ -6,6 +6,7 @@ import React, { memo, useMemo, useState, useEffect } from "react"
 import { Fzf } from "fzf"
 import { formatLargeNumber } from "../../utils/format"
 import { highlightFzfMatch } from "../../utils/highlight"
+import { useCopyToClipboard } from "../../utils/clipboard"
 
 type HistoryViewProps = {
 	onDone: () => void
@@ -18,7 +19,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 	const [searchQuery, setSearchQuery] = useState("")
 	const [sortOption, setSortOption] = useState<SortOption>("newest")
 	const [lastNonRelevantSort, setLastNonRelevantSort] = useState<SortOption | null>("newest")
-	const [showCopyModal, setShowCopyModal] = useState(false)
+	const { showCopyFeedback, copyWithFeedback } = useCopyToClipboard()
 
 	useEffect(() => {
 		if (searchQuery && sortOption !== "mostRelevant" && !lastNonRelevantSort) {
@@ -38,17 +39,6 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 		vscode.postMessage({ type: "deleteTaskWithId", text: id })
 	}
 
-	const handleCopyTask = async (e: React.MouseEvent, task: string) => {
-		e.stopPropagation()
-		try {
-			await navigator.clipboard.writeText(task)
-			setShowCopyModal(true)
-			setTimeout(() => setShowCopyModal(false), 2000)
-		} catch (error) {
-			console.error("Failed to copy to clipboard:", error)
-		}
-	}
-
 	const formatDate = (timestamp: number) => {
 		const date = new Date(timestamp)
 		return date
@@ -144,7 +134,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 					}
 				`}
 			</style>
-			{showCopyModal && <div className="copy-modal">Prompt Copied to Clipboard</div>}
+			{showCopyFeedback && <div className="copy-modal">Prompt Copied to Clipboard</div>}
 			<div
 				style={{
 					position: "fixed",
@@ -271,7 +261,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 												title="Copy Prompt"
 												className="copy-button"
 												data-appearance="icon"
-												onClick={(e) => handleCopyTask(e, item.task)}>
+												onClick={(e) => copyWithFeedback(item.task, e)}>
 												<span className="codicon codicon-copy"></span>
 											</button>
 											<button

+ 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", () => {

+ 7 - 8
webview-ui/src/components/mcp/McpView.tsx

@@ -202,6 +202,13 @@ const ServerRow = ({ server, alwaysAllowMcp }: { server: McpServer; alwaysAllowM
 				<div
 					style={{ display: "flex", alignItems: "center", marginRight: "8px" }}
 					onClick={(e) => e.stopPropagation()}>
+					<VSCodeButton
+						appearance="icon"
+						onClick={handleRestart}
+						disabled={server.status === "connecting"}
+						style={{ marginRight: "8px" }}>
+						<span className="codicon codicon-refresh" style={{ fontSize: "14px" }}></span>
+					</VSCodeButton>
 					<div
 						role="switch"
 						aria-checked={!server.disabled}
@@ -383,14 +390,6 @@ const ServerRow = ({ server, alwaysAllowMcp }: { server: McpServer; alwaysAllowM
 								Maximum time to wait for server responses
 							</span>
 						</div>
-
-						<VSCodeButton
-							appearance="secondary"
-							onClick={handleRestart}
-							disabled={server.status === "connecting"}
-							style={{ width: "calc(100% - 14px)", margin: "0 7px 3px 7px" }}>
-							{server.status === "connecting" ? "Restarting..." : "Restart Server"}
-						</VSCodeButton>
 					</div>
 				)
 			)}

+ 195 - 65
webview-ui/src/components/settings/ApiConfigManager.tsx

@@ -3,6 +3,7 @@ import { memo, useEffect, useRef, useState } from "react"
 import { ApiConfigMeta } from "../../../../src/shared/ExtensionMessage"
 import { Dropdown } from "vscrui"
 import type { DropdownOption } from "vscrui"
+import { Dialog, DialogContent } from "../ui/dialog"
 
 interface ApiConfigManagerProps {
 	currentApiConfigName?: string
@@ -21,55 +22,113 @@ const ApiConfigManager = ({
 	onRenameConfig,
 	onUpsertConfig,
 }: ApiConfigManagerProps) => {
-	const [editState, setEditState] = useState<"new" | "rename" | null>(null)
+	const [isRenaming, setIsRenaming] = useState(false)
+	const [isCreating, setIsCreating] = useState(false)
 	const [inputValue, setInputValue] = useState("")
-	const inputRef = useRef<HTMLInputElement>()
+	const [newProfileName, setNewProfileName] = useState("")
+	const [error, setError] = useState<string | null>(null)
+	const inputRef = useRef<any>(null)
+	const newProfileInputRef = useRef<any>(null)
 
-	// Focus input when entering edit mode
+	const validateName = (name: string, isNewProfile: boolean): string | null => {
+		const trimmed = name.trim()
+		if (!trimmed) return "Name cannot be empty"
+
+		const nameExists = listApiConfigMeta?.some((config) => config.name.toLowerCase() === trimmed.toLowerCase())
+
+		// For new profiles, any existing name is invalid
+		if (isNewProfile && nameExists) {
+			return "A profile with this name already exists"
+		}
+
+		// For rename, only block if trying to rename to a different existing profile
+		if (!isNewProfile && nameExists && trimmed.toLowerCase() !== currentApiConfigName?.toLowerCase()) {
+			return "A profile with this name already exists"
+		}
+
+		return null
+	}
+
+	const resetCreateState = () => {
+		setIsCreating(false)
+		setNewProfileName("")
+		setError(null)
+	}
+
+	const resetRenameState = () => {
+		setIsRenaming(false)
+		setInputValue("")
+		setError(null)
+	}
+
+	// Focus input when entering rename mode
 	useEffect(() => {
-		if (editState) {
-			setTimeout(() => inputRef.current?.focus(), 0)
+		if (isRenaming) {
+			const timeoutId = setTimeout(() => inputRef.current?.focus(), 0)
+			return () => clearTimeout(timeoutId)
 		}
-	}, [editState])
+	}, [isRenaming])
 
-	// Reset edit state when current profile changes
+	// Focus input when opening new dialog
 	useEffect(() => {
-		setEditState(null)
-		setInputValue("")
+		if (isCreating) {
+			const timeoutId = setTimeout(() => newProfileInputRef.current?.focus(), 0)
+			return () => clearTimeout(timeoutId)
+		}
+	}, [isCreating])
+
+	// Reset state when current profile changes
+	useEffect(() => {
+		resetCreateState()
+		resetRenameState()
 	}, [currentApiConfigName])
 
 	const handleAdd = () => {
-		const newConfigName = currentApiConfigName + " (copy)"
-		onUpsertConfig(newConfigName)
+		resetCreateState()
+		setIsCreating(true)
 	}
 
 	const handleStartRename = () => {
-		setEditState("rename")
+		setIsRenaming(true)
 		setInputValue(currentApiConfigName || "")
+		setError(null)
 	}
 
 	const handleCancel = () => {
-		setEditState(null)
-		setInputValue("")
+		resetRenameState()
 	}
 
 	const handleSave = () => {
 		const trimmedValue = inputValue.trim()
-		if (!trimmedValue) return
+		const error = validateName(trimmedValue, false)
 
-		if (editState === "new") {
-			onUpsertConfig(trimmedValue)
-		} else if (editState === "rename" && currentApiConfigName) {
+		if (error) {
+			setError(error)
+			return
+		}
+
+		if (isRenaming && currentApiConfigName) {
 			if (currentApiConfigName === trimmedValue) {
-				setEditState(null)
-				setInputValue("")
+				resetRenameState()
 				return
 			}
 			onRenameConfig(currentApiConfigName, trimmedValue)
 		}
 
-		setEditState(null)
-		setInputValue("")
+		resetRenameState()
+	}
+
+	const handleNewProfileSave = () => {
+		const trimmedValue = newProfileName.trim()
+		const error = validateName(trimmedValue, true)
+
+		if (error) {
+			setError(error)
+			return
+		}
+
+		onUpsertConfig(trimmedValue)
+		resetCreateState()
 	}
 
 	const handleDelete = () => {
@@ -93,49 +152,63 @@ const ApiConfigManager = ({
 					<span style={{ fontWeight: "500" }}>Configuration Profile</span>
 				</label>
 
-				{editState ? (
-					<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
-						<VSCodeTextField
-							ref={inputRef as any}
-							value={inputValue}
-							onInput={(e: any) => setInputValue(e.target.value)}
-							placeholder={editState === "new" ? "Enter profile name" : "Enter new name"}
-							style={{ flexGrow: 1 }}
-							onKeyDown={(e: any) => {
-								if (e.key === "Enter" && inputValue.trim()) {
-									handleSave()
-								} else if (e.key === "Escape") {
-									handleCancel()
-								}
-							}}
-						/>
-						<VSCodeButton
-							appearance="icon"
-							disabled={!inputValue.trim()}
-							onClick={handleSave}
-							title="Save"
-							style={{
-								padding: 0,
-								margin: 0,
-								height: "28px",
-								width: "28px",
-								minWidth: "28px",
-							}}>
-							<span className="codicon codicon-check" />
-						</VSCodeButton>
-						<VSCodeButton
-							appearance="icon"
-							onClick={handleCancel}
-							title="Cancel"
-							style={{
-								padding: 0,
-								margin: 0,
-								height: "28px",
-								width: "28px",
-								minWidth: "28px",
-							}}>
-							<span className="codicon codicon-close" />
-						</VSCodeButton>
+				{isRenaming ? (
+					<div
+						data-testid="rename-form"
+						style={{ display: "flex", gap: "4px", alignItems: "center", flexDirection: "column" }}>
+						<div style={{ display: "flex", gap: "4px", alignItems: "center", width: "100%" }}>
+							<VSCodeTextField
+								ref={inputRef}
+								value={inputValue}
+								onInput={(e: unknown) => {
+									const target = e as { target: { value: string } }
+									setInputValue(target.target.value)
+									setError(null)
+								}}
+								placeholder="Enter new name"
+								style={{ flexGrow: 1 }}
+								onKeyDown={(e: unknown) => {
+									const event = e as { key: string }
+									if (event.key === "Enter" && inputValue.trim()) {
+										handleSave()
+									} else if (event.key === "Escape") {
+										handleCancel()
+									}
+								}}
+							/>
+							<VSCodeButton
+								appearance="icon"
+								disabled={!inputValue.trim()}
+								onClick={handleSave}
+								title="Save"
+								style={{
+									padding: 0,
+									margin: 0,
+									height: "28px",
+									width: "28px",
+									minWidth: "28px",
+								}}>
+								<span className="codicon codicon-check" />
+							</VSCodeButton>
+							<VSCodeButton
+								appearance="icon"
+								onClick={handleCancel}
+								title="Cancel"
+								style={{
+									padding: 0,
+									margin: 0,
+									height: "28px",
+									width: "28px",
+									minWidth: "28px",
+								}}>
+								<span className="codicon codicon-close" />
+							</VSCodeButton>
+						</div>
+						{error && (
+							<p className="text-red-500 text-sm mt-2" data-testid="error-message">
+								{error}
+							</p>
+						)}
 					</div>
 				) : (
 					<>
@@ -211,6 +284,63 @@ const ApiConfigManager = ({
 						</p>
 					</>
 				)}
+
+				<Dialog
+					open={isCreating}
+					onOpenChange={(open: boolean) => {
+						if (open) {
+							setIsCreating(true)
+							setNewProfileName("")
+							setError(null)
+						} else {
+							resetCreateState()
+						}
+					}}
+					aria-labelledby="new-profile-title">
+					<DialogContent className="p-4 max-w-sm">
+						<h2 id="new-profile-title" className="text-lg font-semibold mb-4">
+							New Configuration Profile
+						</h2>
+						<button className="absolute right-4 top-4" aria-label="Close dialog" onClick={resetCreateState}>
+							<span className="codicon codicon-close" />
+						</button>
+						<VSCodeTextField
+							ref={newProfileInputRef}
+							value={newProfileName}
+							onInput={(e: unknown) => {
+								const target = e as { target: { value: string } }
+								setNewProfileName(target.target.value)
+								setError(null)
+							}}
+							placeholder="Enter profile name"
+							style={{ width: "100%" }}
+							onKeyDown={(e: unknown) => {
+								const event = e as { key: string }
+								if (event.key === "Enter" && newProfileName.trim()) {
+									handleNewProfileSave()
+								} else if (event.key === "Escape") {
+									resetCreateState()
+								}
+							}}
+						/>
+						{error && (
+							<p className="text-red-500 text-sm mt-2" data-testid="error-message">
+								{error}
+							</p>
+						)}
+						<div className="flex justify-end gap-2 mt-4">
+							<VSCodeButton appearance="secondary" onClick={resetCreateState}>
+								Cancel
+							</VSCodeButton>
+							<VSCodeButton
+								appearance="primary"
+								disabled={!newProfileName.trim()}
+								onClick={handleNewProfileSave}>
+								Create Profile
+							</VSCodeButton>
+						</div>
+					</DialogContent>
+				</Dialog>
 			</div>
 		</div>
 	)

+ 12 - 142
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,15 +33,14 @@ 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 UnboundModelPicker from "./UnboundModelPicker"
+import { GlamaModelPicker } from "./GlamaModelPicker"
+import { UnboundModelPicker } from "./UnboundModelPicker"
+import { ModelInfoView } from "./ModelInfoView"
+import { DROPDOWN_Z_INDEX } from "./styles"
+
 
 interface ApiOptionsProps {
 	apiErrorMessage?: string
@@ -138,7 +138,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" },
@@ -1388,136 +1388,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"
+	/>
 )

+ 141 - 13
webview-ui/src/components/settings/__tests__/ApiConfigManager.test.tsx

@@ -1,4 +1,4 @@
-import { render, screen, fireEvent } from "@testing-library/react"
+import { render, screen, fireEvent, within } from "@testing-library/react"
 import ApiConfigManager from "../ApiConfigManager"
 
 // Mock VSCode components
@@ -8,11 +8,12 @@ jest.mock("@vscode/webview-ui-toolkit/react", () => ({
 			{children}
 		</button>
 	),
-	VSCodeTextField: ({ value, onInput, placeholder }: any) => (
+	VSCodeTextField: ({ value, onInput, placeholder, onKeyDown }: any) => (
 		<input
 			value={value}
 			onChange={(e) => onInput(e)}
 			placeholder={placeholder}
+			onKeyDown={onKeyDown}
 			ref={undefined} // Explicitly set ref to undefined to avoid warning
 		/>
 	),
@@ -32,6 +33,16 @@ jest.mock("vscrui", () => ({
 	),
 }))
 
+// Mock Dialog component
+jest.mock("@/components/ui/dialog", () => ({
+	Dialog: ({ children, open, onOpenChange }: any) => (
+		<div role="dialog" aria-modal="true" style={{ display: open ? "block" : "none" }} data-testid="dialog">
+			{children}
+		</div>
+	),
+	DialogContent: ({ children }: any) => <div data-testid="dialog-content">{children}</div>,
+}))
+
 describe("ApiConfigManager", () => {
 	const mockOnSelectConfig = jest.fn()
 	const mockOnDeleteConfig = jest.fn()
@@ -54,34 +65,74 @@ describe("ApiConfigManager", () => {
 		jest.clearAllMocks()
 	})
 
-	it("immediately creates a copy when clicking add button", () => {
+	const getRenameForm = () => screen.getByTestId("rename-form")
+	const getDialogContent = () => screen.getByTestId("dialog-content")
+
+	it("opens new profile dialog when clicking add button", () => {
 		render(<ApiConfigManager {...defaultProps} />)
 
-		// Find and click the add button
 		const addButton = screen.getByTitle("Add profile")
 		fireEvent.click(addButton)
 
-		// Verify that onUpsertConfig was called with the correct name
-		expect(mockOnUpsertConfig).toHaveBeenCalledTimes(1)
-		expect(mockOnUpsertConfig).toHaveBeenCalledWith("Default Config (copy)")
+		expect(screen.getByTestId("dialog")).toBeVisible()
+		expect(screen.getByText("New Configuration Profile")).toBeInTheDocument()
 	})
 
-	it("creates copy with correct name when current config has spaces", () => {
-		render(<ApiConfigManager {...defaultProps} currentApiConfigName="My Test Config" />)
+	it("creates new profile with entered name", () => {
+		render(<ApiConfigManager {...defaultProps} />)
 
+		// Open dialog
 		const addButton = screen.getByTitle("Add profile")
 		fireEvent.click(addButton)
 
-		expect(mockOnUpsertConfig).toHaveBeenCalledWith("My Test Config (copy)")
+		// Enter new profile name
+		const input = screen.getByPlaceholderText("Enter profile name")
+		fireEvent.input(input, { target: { value: "New Profile" } })
+
+		// Click create button
+		const createButton = screen.getByText("Create Profile")
+		fireEvent.click(createButton)
+
+		expect(mockOnUpsertConfig).toHaveBeenCalledWith("New Profile")
 	})
 
-	it("handles empty current config name gracefully", () => {
-		render(<ApiConfigManager {...defaultProps} currentApiConfigName="" />)
+	it("shows error when creating profile with existing name", () => {
+		render(<ApiConfigManager {...defaultProps} />)
 
+		// Open dialog
 		const addButton = screen.getByTitle("Add profile")
 		fireEvent.click(addButton)
 
-		expect(mockOnUpsertConfig).toHaveBeenCalledWith(" (copy)")
+		// Enter existing profile name
+		const input = screen.getByPlaceholderText("Enter profile name")
+		fireEvent.input(input, { target: { value: "Default Config" } })
+
+		// Click create button to trigger validation
+		const createButton = screen.getByText("Create Profile")
+		fireEvent.click(createButton)
+
+		// Verify error message
+		const dialogContent = getDialogContent()
+		const errorMessage = within(dialogContent).getByTestId("error-message")
+		expect(errorMessage).toHaveTextContent("A profile with this name already exists")
+		expect(mockOnUpsertConfig).not.toHaveBeenCalled()
+	})
+
+	it("prevents creating profile with empty name", () => {
+		render(<ApiConfigManager {...defaultProps} />)
+
+		// Open dialog
+		const addButton = screen.getByTitle("Add profile")
+		fireEvent.click(addButton)
+
+		// Enter empty name
+		const input = screen.getByPlaceholderText("Enter profile name")
+		fireEvent.input(input, { target: { value: "   " } })
+
+		// Verify create button is disabled
+		const createButton = screen.getByText("Create Profile")
+		expect(createButton).toBeDisabled()
+		expect(mockOnUpsertConfig).not.toHaveBeenCalled()
 	})
 
 	it("allows renaming the current config", () => {
@@ -102,6 +153,45 @@ describe("ApiConfigManager", () => {
 		expect(mockOnRenameConfig).toHaveBeenCalledWith("Default Config", "New Name")
 	})
 
+	it("shows error when renaming to existing config name", () => {
+		render(<ApiConfigManager {...defaultProps} />)
+
+		// Start rename
+		const renameButton = screen.getByTitle("Rename profile")
+		fireEvent.click(renameButton)
+
+		// Find input and enter existing name
+		const input = screen.getByDisplayValue("Default Config")
+		fireEvent.input(input, { target: { value: "Another Config" } })
+
+		// Save to trigger validation
+		const saveButton = screen.getByTitle("Save")
+		fireEvent.click(saveButton)
+
+		// Verify error message
+		const renameForm = getRenameForm()
+		const errorMessage = within(renameForm).getByTestId("error-message")
+		expect(errorMessage).toHaveTextContent("A profile with this name already exists")
+		expect(mockOnRenameConfig).not.toHaveBeenCalled()
+	})
+
+	it("prevents renaming to empty name", () => {
+		render(<ApiConfigManager {...defaultProps} />)
+
+		// Start rename
+		const renameButton = screen.getByTitle("Rename profile")
+		fireEvent.click(renameButton)
+
+		// Find input and enter empty name
+		const input = screen.getByDisplayValue("Default Config")
+		fireEvent.input(input, { target: { value: "   " } })
+
+		// Verify save button is disabled
+		const saveButton = screen.getByTitle("Save")
+		expect(saveButton).toBeDisabled()
+		expect(mockOnRenameConfig).not.toHaveBeenCalled()
+	})
+
 	it("allows selecting a different config", () => {
 		render(<ApiConfigManager {...defaultProps} />)
 
@@ -149,4 +239,42 @@ describe("ApiConfigManager", () => {
 		// Verify we're back to normal view
 		expect(screen.queryByDisplayValue("New Name")).not.toBeInTheDocument()
 	})
+
+	it("handles keyboard events in new profile dialog", () => {
+		render(<ApiConfigManager {...defaultProps} />)
+
+		// Open dialog
+		const addButton = screen.getByTitle("Add profile")
+		fireEvent.click(addButton)
+
+		const input = screen.getByPlaceholderText("Enter profile name")
+
+		// Test Enter key
+		fireEvent.input(input, { target: { value: "New Profile" } })
+		fireEvent.keyDown(input, { key: "Enter" })
+		expect(mockOnUpsertConfig).toHaveBeenCalledWith("New Profile")
+
+		// Test Escape key
+		fireEvent.keyDown(input, { key: "Escape" })
+		expect(screen.getByTestId("dialog")).not.toBeVisible()
+	})
+
+	it("handles keyboard events in rename mode", () => {
+		render(<ApiConfigManager {...defaultProps} />)
+
+		// Start rename
+		const renameButton = screen.getByTitle("Rename profile")
+		fireEvent.click(renameButton)
+
+		const input = screen.getByDisplayValue("Default Config")
+
+		// Test Enter key
+		fireEvent.input(input, { target: { value: "New Name" } })
+		fireEvent.keyDown(input, { key: "Enter" })
+		expect(mockOnRenameConfig).toHaveBeenCalledWith("Default Config", "New Name")
+
+		// Test Escape key
+		fireEvent.keyDown(input, { key: "Escape" })
+		expect(screen.queryByDisplayValue("New Name")).not.toBeInTheDocument()
+	})
 })

+ 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 {

+ 57 - 0
webview-ui/src/utils/clipboard.ts

@@ -0,0 +1,57 @@
+import { useState, useCallback } from "react"
+
+/**
+ * Options for copying text to clipboard
+ */
+interface CopyOptions {
+	/** Duration in ms to show success feedback (default: 2000) */
+	feedbackDuration?: number
+	/** Optional callback when copy succeeds */
+	onSuccess?: () => void
+	/** Optional callback when copy fails */
+	onError?: (error: Error) => void
+}
+
+/**
+ * Copy text to clipboard with error handling
+ */
+export const copyToClipboard = async (text: string, options?: CopyOptions): Promise<boolean> => {
+	try {
+		await navigator.clipboard.writeText(text)
+		options?.onSuccess?.()
+		return true
+	} catch (error) {
+		const err = error instanceof Error ? error : new Error("Failed to copy to clipboard")
+		options?.onError?.(err)
+		console.error("Failed to copy to clipboard:", err)
+		return false
+	}
+}
+
+/**
+ * React hook for managing clipboard copy state with feedback
+ */
+export const useCopyToClipboard = (feedbackDuration = 2000) => {
+	const [showCopyFeedback, setShowCopyFeedback] = useState(false)
+
+	const copyWithFeedback = useCallback(
+		async (text: string, e?: React.MouseEvent) => {
+			e?.stopPropagation()
+
+			const success = await copyToClipboard(text, {
+				onSuccess: () => {
+					setShowCopyFeedback(true)
+					setTimeout(() => setShowCopyFeedback(false), feedbackDuration)
+				},
+			})
+
+			return success
+		},
+		[feedbackDuration],
+	)
+
+	return {
+		showCopyFeedback,
+		copyWithFeedback,
+	}
+}

+ 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)
+}