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

This commit is contained in:
Vignesh Subbiah 2025-02-08 17:46:35 +05:30 committed by GitHub
commit 70ed217149
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 1977 additions and 1523 deletions

View file

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

View file

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

View file

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

View file

@ -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,

View file

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

View file

@ -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,

View file

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

View file

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

View file

@ -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,

View file

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

View file

@ -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(

View file

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

View file

@ -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",
})
}

View file

@ -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) => {

View file

@ -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
}
}

View file

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

View file

@ -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", "support@roocode.com")
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 }
}
}

View file

@ -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", "support@roo.vet")
// 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 })
})
})
})

View file

@ -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()
}
}

View file

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

View file

@ -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==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@testing-library/dom": "^8.5.0",
"@types/react-dom": "^18.0.0"
},
"engines": {
"node": ">=12"
},
"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"
},
"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"
}
},
"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": "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"
},
"engines": {
"node": ">=10",
"node": ">=18"
},
"peerDependencies": {
"@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"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@testing-library/user-event": {
"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",
"engines": {
"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",

View file

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

View file

@ -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">

View file

@ -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={{

View file

@ -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

View file

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

View file

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

View file

@ -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
useEffect(() => {
if (editState) {
setTimeout(() => inputRef.current?.focus(), 0)
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"
}
}, [editState])
// Reset edit state when current profile changes
useEffect(() => {
setEditState(null)
// 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 (isRenaming) {
const timeoutId = setTimeout(() => inputRef.current?.focus(), 0)
return () => clearTimeout(timeoutId)
}
}, [isRenaming])
// Focus input when opening new dialog
useEffect(() => {
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>
)

View file

@ -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

View file

@ -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"
/>
)

View file

@ -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>
)
},
)

View file

@ -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>
)

View file

@ -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>
</>
)
}

View file

@ -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>
)
},
)

View file

@ -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"
/>
)

View file

@ -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()
})
})

View file

@ -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"],
})
})
})

View file

@ -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;
}
}
`

View file

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

View file

@ -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}

View file

@ -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}

View file

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

View file

@ -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,
}
}

View file

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