diff --git a/webview-ui/src/components/settings/providers/ClaudeCode.tsx b/webview-ui/src/components/settings/providers/ClaudeCode.tsx index 5ae5f7de28..5340a8a96b 100644 --- a/webview-ui/src/components/settings/providers/ClaudeCode.tsx +++ b/webview-ui/src/components/settings/providers/ClaudeCode.tsx @@ -1,5 +1,5 @@ -import React from "react" -import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import React, { useState } from "react" +import { VSCodeTextField, VSCodeButton } from "@vscode/webview-ui-toolkit/react" import { type ProviderSettings } from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" import { Slider } from "@src/components/ui" @@ -7,29 +7,54 @@ import { Slider } from "@src/components/ui" interface ClaudeCodeProps { apiConfiguration: ProviderSettings setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void + vscode?: any } -export const ClaudeCode: React.FC = ({ apiConfiguration, setApiConfigurationField }) => { +export const ClaudeCode: React.FC = ({ apiConfiguration, setApiConfigurationField, vscode }) => { const { t } = useAppTranslation() + const [isAuthenticating, setIsAuthenticating] = useState(false) const handleInputChange = (e: Event | React.FormEvent) => { const element = e.target as HTMLInputElement setApiConfigurationField("claudeCodePath", element.value) } + const handleGetApiKey = () => { + setIsAuthenticating(true) + // Open Claude's website to get API key + vscode?.postMessage({ + type: "openExternal", + url: "https://console.anthropic.com/settings/keys", + }) + // Show instructions + vscode?.postMessage({ + type: "showInformationMessage", + text: "Please create an API key on the Anthropic Console and paste it in the field above", + }) + setIsAuthenticating(false) + } + const maxOutputTokens = apiConfiguration?.claudeCodeMaxOutputTokens || 8000 + const hasPath = !!apiConfiguration?.claudeCodePath return (
- - {t("settings:providers.claudeCode.pathLabel")} - +
+ + {t("settings:providers.claudeCode.pathLabel")} + + + {hasPath + ? t("settings:providers.claudeCode.updateKey") + : t("settings:providers.claudeCode.getApiKey")} + +

= ({ apiConfiguration, setApi }}> {t("settings:providers.claudeCode.description")}

+ + {hasPath && ( +

+ ✓ {t("settings:providers.claudeCode.authenticated")} +

+ )}
diff --git a/webview-ui/src/components/settings/providers/OpenAI.tsx b/webview-ui/src/components/settings/providers/OpenAI.tsx index 59b907c45a..287c1d2e9b 100644 --- a/webview-ui/src/components/settings/providers/OpenAI.tsx +++ b/webview-ui/src/components/settings/providers/OpenAI.tsx @@ -69,7 +69,16 @@ export const OpenAI = ({ apiConfiguration, setApiConfigurationField, selectedMod
{t("settings:providers.apiKeyStorageNotice")}
- {!apiConfiguration?.openAiNativeApiKey && ( + {apiConfiguration?.openAiNativeApiKey ? ( +
+
+ ✓ {t("settings:providers.openAi.authenticated")} +
+ + {t("settings:providers.openAi.updateKey")} + +
+ ) : ( {t("settings:providers.getOpenAiApiKey")} diff --git a/webview-ui/src/components/settings/providers/__tests__/ClaudeCode.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/ClaudeCode.spec.tsx new file mode 100644 index 0000000000..e810b495c0 --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/ClaudeCode.spec.tsx @@ -0,0 +1,219 @@ +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { ClaudeCode } from "../ClaudeCode" +import type { ProviderSettings } from "@roo-code/types" + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeTextField: ({ children, value, onInput, type, placeholder }: any) => { + const handleInput = (e: any) => { + const event = { target: { value: e.target.value } } + onInput?.(event) + } + return ( +
+ {children} + +
+ ) + }, + VSCodeButton: ({ children, onClick, disabled, appearance }: any) => ( + + ), +})) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "settings:providers.claudeCode.pathLabel": "Claude Code API Key", + "settings:providers.claudeCode.placeholder": "Enter your Claude Code API key", + "settings:providers.claudeCode.getApiKey": "Get API Key", + "settings:providers.claudeCode.updateKey": "Update Key", + "settings:providers.claudeCode.authenticated": "Authenticated with Claude Code", + "settings:providers.claudeCode.description": + "Optional path to your Claude Code CLI. Defaults to 'claude' if not set.", + "settings:providers.claudeCode.maxTokensLabel": "Max Output Tokens", + "settings:providers.claudeCode.maxTokensDescription": + "Maximum number of output tokens for Claude Code responses. Default is 8000.", + } + return translations[key] || key + }, + }), +})) + +vi.mock("@src/components/ui", () => ({ + Slider: ({ value, onValueChange }: any) => ( + onValueChange([Number(e.target.value)])} + data-testid="slider" + /> + ), +})) + +describe("ClaudeCode", () => { + const defaultApiConfiguration: ProviderSettings = { + claudeCodePath: "", + claudeCodeMaxOutputTokens: 8000, + } + + const mockSetApiConfigurationField = vi.fn() + const mockVscode = { + postMessage: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("Authentication UI", () => { + it("should show 'Get API Key' button when no API key is present", () => { + render( + , + ) + + const button = screen.getByRole("button") + expect(button).toHaveTextContent("Get API Key") + }) + + it("should show 'Update Key' button when API key is present", () => { + const apiConfiguration = { ...defaultApiConfiguration, claudeCodePath: "test-api-key" } + render( + , + ) + + const button = screen.getByRole("button") + expect(button).toHaveTextContent("Update Key") + }) + + it("should show authenticated status when API key is present", () => { + const apiConfiguration = { ...defaultApiConfiguration, claudeCodePath: "test-api-key" } + render( + , + ) + + expect(screen.getByText("✓ Authenticated with Claude Code")).toBeInTheDocument() + }) + + it("should not show authenticated status when no API key", () => { + render( + , + ) + + expect(screen.queryByText("✓ Authenticated with Claude Code")).not.toBeInTheDocument() + }) + + it("should open external link when Get API Key button is clicked", async () => { + const user = userEvent.setup() + render( + , + ) + + const button = screen.getByRole("button") + await user.click(button) + + expect(mockVscode.postMessage).toHaveBeenCalledWith({ + type: "openExternal", + url: "https://console.anthropic.com/settings/keys", + }) + }) + + it("should show information message when Get API Key button is clicked", async () => { + const user = userEvent.setup() + render( + , + ) + + const button = screen.getByRole("button") + await user.click(button) + + expect(mockVscode.postMessage).toHaveBeenCalledWith({ + type: "showInformationMessage", + text: "Please create an API key on the Anthropic Console and paste it in the field above", + }) + }) + }) + + describe("API Key Input", () => { + it("should mask API key input", () => { + render( + , + ) + + const input = screen.getByPlaceholderText("Enter your Claude Code API key") as HTMLInputElement + expect(input.type).toBe("password") + }) + + it("should show masked placeholder when API key exists", () => { + const apiConfiguration = { ...defaultApiConfiguration, claudeCodePath: "test-api-key" } + render( + , + ) + + const input = screen.getByPlaceholderText("••••••••••••••••") as HTMLInputElement + expect(input).toBeInTheDocument() + }) + }) + + describe("Max Output Tokens", () => { + it("should display current max output tokens value", () => { + const apiConfiguration = { ...defaultApiConfiguration, claudeCodeMaxOutputTokens: 16000 } + render( + , + ) + + expect(screen.getByText("16000")).toBeInTheDocument() + }) + + it("should use default value of 8000 when not specified", () => { + render( + , + ) + + expect(screen.getByText("8000")).toBeInTheDocument() + }) + }) +}) diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenAI.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAI.spec.tsx new file mode 100644 index 0000000000..76035cd52e --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/OpenAI.spec.tsx @@ -0,0 +1,269 @@ +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { OpenAI } from "../OpenAI" +import type { ProviderSettings, ModelInfo } from "@roo-code/types" + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeTextField: ({ children, value, onInput, type, placeholder }: any) => { + const handleInput = (e: any) => { + const event = { target: { value: e.target.value } } + onInput?.(event) + } + return ( +
+ {children && } + +
+ ) + }, +})) + +vi.mock("vscrui", () => ({ + Checkbox: ({ children, checked, onChange }: any) => ( + + ), +})) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "settings:providers.openAiApiKey": "OpenAI API Key", + "settings:providers.getOpenAiApiKey": "Get OpenAI API Key", + "settings:providers.openAi.authenticated": "Authenticated with OpenAI", + "settings:providers.openAi.updateKey": "Update API Key", + "settings:providers.apiKeyStorageNotice": "API keys are stored securely in VSCode's Secret Storage", + "settings:providers.useCustomBaseUrl": "Use custom base URL", + "settings:placeholders.apiKey": "Enter API Key...", + "settings:common.select": "Select", + } + return translations[key] || key + }, + }), +})) + +vi.mock("@src/components/common/VSCodeButtonLink", () => ({ + VSCodeButtonLink: ({ children, href, appearance }: any) => ( + + {children} + + ), +})) + +vi.mock("@src/components/ui", () => ({ + Select: ({ children, value, onValueChange }: any) => ( + + ), + SelectTrigger: ({ children }: any) =>
{children}
, + SelectValue: ({ placeholder }: any) => {placeholder}, + SelectContent: ({ children }: any) => <>{children}, + SelectItem: ({ children, value }: any) => , + StandardTooltip: ({ children, content }: any) => {children}, +})) + +describe("OpenAI", () => { + const defaultApiConfiguration: ProviderSettings = { + openAiNativeApiKey: "", + openAiNativeBaseUrl: "", + openAiNativeServiceTier: "default", + } + + const mockSetApiConfigurationField = vi.fn() + const mockSelectedModelInfo: ModelInfo = { + contextWindow: 128000, + maxTokens: 8000, + supportsPromptCache: false, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("Authentication UI", () => { + it("should show 'Get OpenAI API Key' button when no API key is present", () => { + render( + , + ) + + const link = screen.getByText("Get OpenAI API Key") + expect(link).toBeInTheDocument() + expect(link.closest("a")).toHaveAttribute("href", "https://platform.openai.com/api-keys") + }) + + it("should show authenticated status and update button when API key is present", () => { + const apiConfiguration = { ...defaultApiConfiguration, openAiNativeApiKey: "test-api-key" } + render( + , + ) + + expect(screen.getByText("✓ Authenticated with OpenAI")).toBeInTheDocument() + const updateLink = screen.getByText("Update API Key") + expect(updateLink).toBeInTheDocument() + expect(updateLink.closest("a")).toHaveAttribute("href", "https://platform.openai.com/api-keys") + }) + + it("should not show authenticated status when no API key", () => { + render( + , + ) + + expect(screen.queryByText("✓ Authenticated with OpenAI")).not.toBeInTheDocument() + }) + }) + + describe("API Key Input", () => { + it("should mask API key input", () => { + render( + , + ) + + const input = screen.getByPlaceholderText("Enter API Key...") as HTMLInputElement + expect(input.type).toBe("password") + }) + + it("should show API key storage notice", () => { + render( + , + ) + + expect(screen.getByText("API keys are stored securely in VSCode's Secret Storage")).toBeInTheDocument() + }) + }) + + describe("Custom Base URL", () => { + it("should not show base URL input by default", () => { + render( + , + ) + + expect(screen.queryByPlaceholderText("https://api.openai.com/v1")).not.toBeInTheDocument() + }) + + it("should show base URL input when checkbox is checked", async () => { + const user = userEvent.setup() + render( + , + ) + + const checkbox = screen.getByRole("checkbox") + await user.click(checkbox) + + expect(screen.getByPlaceholderText("https://api.openai.com/v1")).toBeInTheDocument() + }) + + it("should clear base URL when checkbox is unchecked", async () => { + const user = userEvent.setup() + const apiConfiguration = { ...defaultApiConfiguration, openAiNativeBaseUrl: "https://custom.url/v1" } + render( + , + ) + + const checkbox = screen.getByRole("checkbox") + await user.click(checkbox) + + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("openAiNativeBaseUrl", "") + }) + }) + + describe("Service Tier", () => { + it("should not show service tier selector when model has no tiers", () => { + render( + , + ) + + expect(screen.queryByTestId("openai-service-tier")).not.toBeInTheDocument() + }) + + it("should show service tier selector when model has flex or priority tiers", () => { + const modelInfo: ModelInfo = { + contextWindow: 128000, + maxTokens: 8000, + supportsPromptCache: false, + tiers: [ + { name: "flex", contextWindow: 128000 }, + { name: "priority", contextWindow: 128000 }, + ], + } + + render( + , + ) + + expect(screen.getByTestId("openai-service-tier")).toBeInTheDocument() + expect(screen.getByText("Standard")).toBeInTheDocument() + expect(screen.getByText("Flex")).toBeInTheDocument() + expect(screen.getByText("Priority")).toBeInTheDocument() + }) + + it("should update service tier when selection changes", async () => { + const modelInfo: ModelInfo = { + contextWindow: 128000, + maxTokens: 8000, + supportsPromptCache: false, + tiers: [ + { name: "flex", contextWindow: 128000 }, + { name: "priority", contextWindow: 128000 }, + ], + } + + const user = userEvent.setup() + render( + , + ) + + const select = screen.getByRole("combobox") + await user.selectOptions(select, "priority") + + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("openAiNativeServiceTier", "priority") + }) + }) +}) diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index efcd4ffa33..a6a655e0a2 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -332,6 +332,10 @@ "apiKey": "API Key", "openAiBaseUrl": "Base URL", "getOpenAiApiKey": "Get OpenAI API Key", + "openAi": { + "authenticated": "Authenticated with OpenAI", + "updateKey": "Update API Key" + }, "mistralApiKey": "Mistral API Key", "getMistralApiKey": "Get Mistral / Codestral API Key", "codestralBaseUrl": "Codestral Base URL (Optional)", @@ -488,9 +492,12 @@ }, "setReasoningLevel": "Enable Reasoning Effort", "claudeCode": { - "pathLabel": "Claude Code Path", + "pathLabel": "Claude Code API Key", "description": "Optional path to your Claude Code CLI. Defaults to 'claude' if not set.", - "placeholder": "Default: claude", + "placeholder": "Enter your Claude Code API key", + "getApiKey": "Get API Key", + "updateKey": "Update Key", + "authenticated": "Authenticated with Claude Code", "maxTokensLabel": "Max Output Tokens", "maxTokensDescription": "Maximum number of output tokens for Claude Code responses. Default is 8000." }