Merge pull request #979 from System233/patch-input-boxes

Fix input box revert issue and configuration loss during profile switch #955
This commit is contained in:
Matt Rubens 2025-02-17 17:05:38 -05:00 committed by GitHub
commit 17be7c18f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 276 additions and 233 deletions

View file

@ -33,29 +33,38 @@ export class ConfigManager {
return Math.random().toString(36).substring(2, 15)
}
// Synchronize readConfig/writeConfig operations to avoid data loss.
private _lock = Promise.resolve()
private lock<T>(cb: () => Promise<T>) {
const next = this._lock.then(cb)
this._lock = next.catch(() => {}) as Promise<void>
return next
}
/**
* Initialize config if it doesn't exist
*/
async initConfig(): Promise<void> {
try {
const config = await this.readConfig()
if (!config) {
await this.writeConfig(this.defaultConfig)
return
}
// Migrate: ensure all configs have IDs
let needsMigration = false
for (const [name, apiConfig] of Object.entries(config.apiConfigs)) {
if (!apiConfig.id) {
apiConfig.id = this.generateId()
needsMigration = true
return await this.lock(async () => {
const config = await this.readConfig()
if (!config) {
await this.writeConfig(this.defaultConfig)
return
}
}
if (needsMigration) {
await this.writeConfig(config)
}
// Migrate: ensure all configs have IDs
let needsMigration = false
for (const [name, apiConfig] of Object.entries(config.apiConfigs)) {
if (!apiConfig.id) {
apiConfig.id = this.generateId()
needsMigration = true
}
}
if (needsMigration) {
await this.writeConfig(config)
}
})
} catch (error) {
throw new Error(`Failed to initialize config: ${error}`)
}
@ -66,12 +75,14 @@ export class ConfigManager {
*/
async listConfig(): Promise<ApiConfigMeta[]> {
try {
const config = await this.readConfig()
return Object.entries(config.apiConfigs).map(([name, apiConfig]) => ({
name,
id: apiConfig.id || "",
apiProvider: apiConfig.apiProvider,
}))
return await this.lock(async () => {
const config = await this.readConfig()
return Object.entries(config.apiConfigs).map(([name, apiConfig]) => ({
name,
id: apiConfig.id || "",
apiProvider: apiConfig.apiProvider,
}))
})
} catch (error) {
throw new Error(`Failed to list configs: ${error}`)
}
@ -82,13 +93,15 @@ export class ConfigManager {
*/
async saveConfig(name: string, config: ApiConfiguration): Promise<void> {
try {
const currentConfig = await this.readConfig()
const existingConfig = currentConfig.apiConfigs[name]
currentConfig.apiConfigs[name] = {
...config,
id: existingConfig?.id || this.generateId(),
}
await this.writeConfig(currentConfig)
return await this.lock(async () => {
const currentConfig = await this.readConfig()
const existingConfig = currentConfig.apiConfigs[name]
currentConfig.apiConfigs[name] = {
...config,
id: existingConfig?.id || this.generateId(),
}
await this.writeConfig(currentConfig)
})
} catch (error) {
throw new Error(`Failed to save config: ${error}`)
}
@ -99,17 +112,19 @@ export class ConfigManager {
*/
async loadConfig(name: string): Promise<ApiConfiguration> {
try {
const config = await this.readConfig()
const apiConfig = config.apiConfigs[name]
return await this.lock(async () => {
const config = await this.readConfig()
const apiConfig = config.apiConfigs[name]
if (!apiConfig) {
throw new Error(`Config '${name}' not found`)
}
if (!apiConfig) {
throw new Error(`Config '${name}' not found`)
}
config.currentApiConfigName = name
await this.writeConfig(config)
config.currentApiConfigName = name
await this.writeConfig(config)
return apiConfig
return apiConfig
})
} catch (error) {
throw new Error(`Failed to load config: ${error}`)
}
@ -120,18 +135,20 @@ export class ConfigManager {
*/
async deleteConfig(name: string): Promise<void> {
try {
const currentConfig = await this.readConfig()
if (!currentConfig.apiConfigs[name]) {
throw new Error(`Config '${name}' not found`)
}
return await this.lock(async () => {
const currentConfig = await this.readConfig()
if (!currentConfig.apiConfigs[name]) {
throw new Error(`Config '${name}' not found`)
}
// Don't allow deleting the default config
if (Object.keys(currentConfig.apiConfigs).length === 1) {
throw new Error(`Cannot delete the last remaining configuration.`)
}
// Don't allow deleting the default config
if (Object.keys(currentConfig.apiConfigs).length === 1) {
throw new Error(`Cannot delete the last remaining configuration.`)
}
delete currentConfig.apiConfigs[name]
await this.writeConfig(currentConfig)
delete currentConfig.apiConfigs[name]
await this.writeConfig(currentConfig)
})
} catch (error) {
throw new Error(`Failed to delete config: ${error}`)
}
@ -142,13 +159,15 @@ export class ConfigManager {
*/
async setCurrentConfig(name: string): Promise<void> {
try {
const currentConfig = await this.readConfig()
if (!currentConfig.apiConfigs[name]) {
throw new Error(`Config '${name}' not found`)
}
return await this.lock(async () => {
const currentConfig = await this.readConfig()
if (!currentConfig.apiConfigs[name]) {
throw new Error(`Config '${name}' not found`)
}
currentConfig.currentApiConfigName = name
await this.writeConfig(currentConfig)
currentConfig.currentApiConfigName = name
await this.writeConfig(currentConfig)
})
} catch (error) {
throw new Error(`Failed to set current config: ${error}`)
}
@ -159,8 +178,10 @@ export class ConfigManager {
*/
async hasConfig(name: string): Promise<boolean> {
try {
const config = await this.readConfig()
return name in config.apiConfigs
return await this.lock(async () => {
const config = await this.readConfig()
return name in config.apiConfigs
})
} catch (error) {
throw new Error(`Failed to check config existence: ${error}`)
}
@ -171,12 +192,14 @@ export class ConfigManager {
*/
async setModeConfig(mode: Mode, configId: string): Promise<void> {
try {
const currentConfig = await this.readConfig()
if (!currentConfig.modeApiConfigs) {
currentConfig.modeApiConfigs = {}
}
currentConfig.modeApiConfigs[mode] = configId
await this.writeConfig(currentConfig)
return await this.lock(async () => {
const currentConfig = await this.readConfig()
if (!currentConfig.modeApiConfigs) {
currentConfig.modeApiConfigs = {}
}
currentConfig.modeApiConfigs[mode] = configId
await this.writeConfig(currentConfig)
})
} catch (error) {
throw new Error(`Failed to set mode config: ${error}`)
}
@ -187,8 +210,10 @@ export class ConfigManager {
*/
async getModeConfigId(mode: Mode): Promise<string | undefined> {
try {
const config = await this.readConfig()
return config.modeApiConfigs?.[mode]
return await this.lock(async () => {
const config = await this.readConfig()
return config.modeApiConfigs?.[mode]
})
} catch (error) {
throw new Error(`Failed to get mode config: ${error}`)
}
@ -205,7 +230,9 @@ export class ConfigManager {
* Reset all configuration by deleting the stored config from secrets
*/
public async resetAllConfigs(): Promise<void> {
await this.context.secrets.delete(this.getConfigKey())
return await this.lock(async () => {
await this.context.secrets.delete(this.getConfigKey())
})
}
private async readConfig(): Promise<ApiConfigData> {

View file

@ -1317,6 +1317,20 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
break
}
case "saveApiConfiguration":
if (message.text && message.apiConfiguration) {
try {
await this.configManager.saveConfig(message.text, message.apiConfiguration)
const listApiConfig = await this.configManager.listConfig()
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
} catch (error) {
this.outputChannel.appendLine(
`Error save api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
vscode.window.showErrorMessage("Failed to save api configuration")
}
}
break
case "upsertApiConfiguration":
if (message.text && message.apiConfiguration) {
try {
@ -1361,9 +1375,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.postStateToWebview()
} catch (error) {
this.outputChannel.appendLine(
`Error create new api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
`Error rename api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
vscode.window.showErrorMessage("Failed to create api configuration")
vscode.window.showErrorMessage("Failed to rename api configuration")
}
}
break
@ -1647,51 +1661,53 @@ export class ClineProvider implements vscode.WebviewViewProvider {
requestyModelInfo,
modelTemperature,
} = apiConfiguration
await this.updateGlobalState("apiProvider", apiProvider)
await this.updateGlobalState("apiModelId", apiModelId)
await this.storeSecret("apiKey", apiKey)
await this.updateGlobalState("glamaModelId", glamaModelId)
await this.updateGlobalState("glamaModelInfo", glamaModelInfo)
await this.storeSecret("glamaApiKey", glamaApiKey)
await this.storeSecret("openRouterApiKey", openRouterApiKey)
await this.storeSecret("awsAccessKey", awsAccessKey)
await this.storeSecret("awsSecretKey", awsSecretKey)
await this.storeSecret("awsSessionToken", awsSessionToken)
await this.updateGlobalState("awsRegion", awsRegion)
await this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference)
await this.updateGlobalState("awsProfile", awsProfile)
await this.updateGlobalState("awsUseProfile", awsUseProfile)
await this.updateGlobalState("vertexProjectId", vertexProjectId)
await this.updateGlobalState("vertexRegion", vertexRegion)
await this.updateGlobalState("openAiBaseUrl", openAiBaseUrl)
await this.storeSecret("openAiApiKey", openAiApiKey)
await this.updateGlobalState("openAiModelId", openAiModelId)
await this.updateGlobalState("openAiCustomModelInfo", openAiCustomModelInfo)
await this.updateGlobalState("openAiUseAzure", openAiUseAzure)
await this.updateGlobalState("ollamaModelId", ollamaModelId)
await this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl)
await this.updateGlobalState("lmStudioModelId", lmStudioModelId)
await this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl)
await this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl)
await this.storeSecret("geminiApiKey", geminiApiKey)
await this.storeSecret("openAiNativeApiKey", openAiNativeApiKey)
await this.storeSecret("deepSeekApiKey", deepSeekApiKey)
await this.updateGlobalState("azureApiVersion", azureApiVersion)
await this.updateGlobalState("openAiStreamingEnabled", openAiStreamingEnabled)
await this.updateGlobalState("openRouterModelId", openRouterModelId)
await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo)
await this.updateGlobalState("openRouterBaseUrl", openRouterBaseUrl)
await this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform)
await this.updateGlobalState("vsCodeLmModelSelector", vsCodeLmModelSelector)
await this.storeSecret("mistralApiKey", mistralApiKey)
await this.updateGlobalState("mistralCodestralUrl", mistralCodestralUrl)
await this.storeSecret("unboundApiKey", unboundApiKey)
await this.updateGlobalState("unboundModelId", unboundModelId)
await this.updateGlobalState("unboundModelInfo", unboundModelInfo)
await this.storeSecret("requestyApiKey", requestyApiKey)
await this.updateGlobalState("requestyModelId", requestyModelId)
await this.updateGlobalState("requestyModelInfo", requestyModelInfo)
await this.updateGlobalState("modelTemperature", modelTemperature)
await Promise.all([
this.updateGlobalState("apiProvider", apiProvider),
this.updateGlobalState("apiModelId", apiModelId),
this.storeSecret("apiKey", apiKey),
this.updateGlobalState("glamaModelId", glamaModelId),
this.updateGlobalState("glamaModelInfo", glamaModelInfo),
this.storeSecret("glamaApiKey", glamaApiKey),
this.storeSecret("openRouterApiKey", openRouterApiKey),
this.storeSecret("awsAccessKey", awsAccessKey),
this.storeSecret("awsSecretKey", awsSecretKey),
this.storeSecret("awsSessionToken", awsSessionToken),
this.updateGlobalState("awsRegion", awsRegion),
this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference),
this.updateGlobalState("awsProfile", awsProfile),
this.updateGlobalState("awsUseProfile", awsUseProfile),
this.updateGlobalState("vertexProjectId", vertexProjectId),
this.updateGlobalState("vertexRegion", vertexRegion),
this.updateGlobalState("openAiBaseUrl", openAiBaseUrl),
this.storeSecret("openAiApiKey", openAiApiKey),
this.updateGlobalState("openAiModelId", openAiModelId),
this.updateGlobalState("openAiCustomModelInfo", openAiCustomModelInfo),
this.updateGlobalState("openAiUseAzure", openAiUseAzure),
this.updateGlobalState("ollamaModelId", ollamaModelId),
this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl),
this.updateGlobalState("lmStudioModelId", lmStudioModelId),
this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl),
this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl),
this.storeSecret("geminiApiKey", geminiApiKey),
this.storeSecret("openAiNativeApiKey", openAiNativeApiKey),
this.storeSecret("deepSeekApiKey", deepSeekApiKey),
this.updateGlobalState("azureApiVersion", azureApiVersion),
this.updateGlobalState("openAiStreamingEnabled", openAiStreamingEnabled),
this.updateGlobalState("openRouterModelId", openRouterModelId),
this.updateGlobalState("openRouterModelInfo", openRouterModelInfo),
this.updateGlobalState("openRouterBaseUrl", openRouterBaseUrl),
this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform),
this.updateGlobalState("vsCodeLmModelSelector", vsCodeLmModelSelector),
this.storeSecret("mistralApiKey", mistralApiKey),
this.updateGlobalState("mistralCodestralUrl", mistralCodestralUrl),
this.storeSecret("unboundApiKey", unboundApiKey),
this.updateGlobalState("unboundModelId", unboundModelId),
this.updateGlobalState("unboundModelInfo", unboundModelInfo),
this.storeSecret("requestyApiKey", requestyApiKey),
this.updateGlobalState("requestyModelId", requestyModelId),
this.updateGlobalState("requestyModelInfo", requestyModelInfo),
this.updateGlobalState("modelTemperature", modelTemperature),
])
if (this.cline) {
this.cline.api = buildApiHandler(apiConfiguration)
}

View file

@ -1405,5 +1405,38 @@ describe("ClineProvider", () => {
])
expect(mockContext.globalState.update).toHaveBeenCalledWith("currentApiConfigName", "test-config")
})
test("handles successful saveApiConfiguration", async () => {
provider.resolveWebviewView(mockWebviewView)
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
// Mock ConfigManager methods
provider.configManager = {
saveConfig: jest.fn().mockResolvedValue(undefined),
listConfig: jest
.fn()
.mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]),
} as any
const testApiConfig = {
apiProvider: "anthropic" as const,
apiKey: "test-key",
}
// Trigger upsertApiConfiguration
await messageHandler({
type: "saveApiConfiguration",
text: "test-config",
apiConfiguration: testApiConfig,
})
// Verify config was saved
expect(provider.configManager.saveConfig).toHaveBeenCalledWith("test-config", testApiConfig)
// Verify state updates
expect(mockContext.globalState.update).toHaveBeenCalledWith("listApiConfigMeta", [
{ name: "test-config", id: "test-id", apiProvider: "anthropic" },
])
})
})
})

View file

@ -12,6 +12,7 @@ export interface WebviewMessage {
type:
| "apiConfiguration"
| "currentApiConfigName"
| "saveApiConfiguration"
| "upsertApiConfiguration"
| "deleteApiConfiguration"
| "loadApiConfiguration"

View file

@ -41,7 +41,7 @@
"tailwind-merge": "^2.6.0",
"tailwindcss": "^4.0.0",
"tailwindcss-animate": "^1.0.7",
"vscrui": "^0.2.0"
"vscrui": "^0.2.2"
},
"devDependencies": {
"@storybook/addon-essentials": "^8.5.6",
@ -19841,9 +19841,9 @@
}
},
"node_modules/vscrui": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/vscrui/-/vscrui-0.2.0.tgz",
"integrity": "sha512-fvxZM/uIYOMN3fUbE2In+R1VrNj8PKcfAdh+Us2bJaPGuG9ySkR6xkV2aJVqXxWDX77U3v/UQGc5e7URrB52Gw==",
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/vscrui/-/vscrui-0.2.2.tgz",
"integrity": "sha512-buw2OipqUl7GCBq1mxcAjUwoUsslGzVhdaxDPmEx27xzc3QAJJZHtT30QbakgZVJ1Jb3E6kcsguUIFEGxrgkyQ==",
"license": "MIT",
"funding": {
"type": "github",
@ -19851,7 +19851,7 @@
},
"peerDependencies": {
"@types/react": "*",
"react": "^17 || ^18"
"react": "^17 || ^18 || ^19"
}
},
"node_modules/w3c-hr-time": {

View file

@ -48,7 +48,7 @@
"tailwind-merge": "^2.6.0",
"tailwindcss": "^4.0.0",
"tailwindcss-animate": "^1.0.7",
"vscrui": "^0.2.0"
"vscrui": "^0.2.2"
},
"devDependencies": {
"@storybook/addon-essentials": "^8.5.6",

View file

@ -3,7 +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"
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"
interface ApiConfigManagerProps {
currentApiConfigName?: string
@ -298,9 +298,7 @@ const ApiConfigManager = ({
}}
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>
<DialogTitle>New Configuration Profile</DialogTitle>
<button className="absolute right-4 top-4" aria-label="Close dialog" onClick={resetCreateState}>
<span className="codicon codicon-close" />
</button>

View file

@ -1,5 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useState } from "react"
import { useEvent, useInterval } from "react-use"
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useEvent } from "react-use"
import { Checkbox, Dropdown, Pane, type DropdownOption } from "vscrui"
import { VSCodeLink, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { TemperatureControl } from "./TemperatureControl"
@ -65,7 +65,8 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
return normalizeApiConfiguration(apiConfiguration)
}, [apiConfiguration])
// Poll ollama/lmstudio models
const requestLocalModelsTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// Pull ollama/lmstudio models
const requestLocalModels = useCallback(() => {
if (selectedProvider === "ollama") {
vscode.postMessage({ type: "requestOllamaModels", text: apiConfiguration?.ollamaBaseUrl })
@ -75,34 +76,29 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
vscode.postMessage({ type: "requestVsCodeLmModels" })
}
}, [selectedProvider, apiConfiguration?.ollamaBaseUrl, apiConfiguration?.lmStudioBaseUrl])
// Debounced model updates, only executed 250ms after the user stops typing
useEffect(() => {
if (selectedProvider === "ollama" || selectedProvider === "lmstudio" || selectedProvider === "vscode-lm") {
requestLocalModels()
if (requestLocalModelsTimeoutRef.current) {
clearTimeout(requestLocalModelsTimeoutRef.current)
}
}, [selectedProvider, requestLocalModels])
useInterval(
requestLocalModels,
selectedProvider === "ollama" || selectedProvider === "lmstudio" || selectedProvider === "vscode-lm"
? 2000
: null,
)
requestLocalModelsTimeoutRef.current = setTimeout(requestLocalModels, 250)
return () => {
if (requestLocalModelsTimeoutRef.current) {
clearTimeout(requestLocalModelsTimeoutRef.current)
}
}
}, [requestLocalModels])
const handleMessage = useCallback((event: MessageEvent) => {
const message: ExtensionMessage = event.data
if (message.type === "ollamaModels" && Array.isArray(message.ollamaModels)) {
const newModels = message.ollamaModels
setOllamaModels((prevModels) => {
return JSON.stringify(prevModels) === JSON.stringify(newModels) ? prevModels : newModels
})
setOllamaModels(newModels)
} else if (message.type === "lmStudioModels" && Array.isArray(message.lmStudioModels)) {
const newModels = message.lmStudioModels
setLmStudioModels((prevModels) => {
return JSON.stringify(prevModels) === JSON.stringify(newModels) ? prevModels : newModels
})
setLmStudioModels(newModels)
} else if (message.type === "vsCodeLmModels" && Array.isArray(message.vsCodeLmModels)) {
const newModels = message.vsCodeLmModels
setVsCodeLmModels((prevModels) => {
return JSON.stringify(prevModels) === JSON.stringify(newModels) ? prevModels : newModels
})
setVsCodeLmModels(newModels)
}
}, [])
useEvent("message", handleMessage)
@ -142,10 +138,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
id="api-provider"
value={selectedProvider}
onChange={(value: unknown) => {
handleInputChange(
"apiProvider",
true,
)({
handleInputChange("apiProvider")({
target: {
value: (value as DropdownOption).value,
},
@ -178,7 +171,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
value={apiConfiguration?.apiKey || ""}
style={{ width: "100%" }}
type="password"
onBlur={handleInputChange("apiKey")}
onInput={handleInputChange("apiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>Anthropic API Key</span>
</VSCodeTextField>
@ -203,7 +196,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
value={apiConfiguration?.anthropicBaseUrl || ""}
style={{ width: "100%", marginTop: 3 }}
type="url"
onBlur={handleInputChange("anthropicBaseUrl")}
onInput={handleInputChange("anthropicBaseUrl")}
placeholder="Default: https://api.anthropic.com"
/>
)}
@ -232,7 +225,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
value={apiConfiguration?.glamaApiKey || ""}
style={{ width: "100%" }}
type="password"
onBlur={handleInputChange("glamaApiKey")}
onInput={handleInputChange("glamaApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>Glama API Key</span>
</VSCodeTextField>
@ -261,7 +254,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
value={apiConfiguration?.requestyApiKey || ""}
style={{ width: "100%" }}
type="password"
onBlur={handleInputChange("requestyApiKey")}
onInput={handleInputChange("requestyApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>Requesty API Key</span>
</VSCodeTextField>
@ -282,7 +275,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
value={apiConfiguration?.openAiNativeApiKey || ""}
style={{ width: "100%" }}
type="password"
onBlur={handleInputChange("openAiNativeApiKey")}
onInput={handleInputChange("openAiNativeApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>OpenAI API Key</span>
</VSCodeTextField>
@ -310,7 +303,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
value={apiConfiguration?.mistralApiKey || ""}
style={{ width: "100%" }}
type="password"
onBlur={handleInputChange("mistralApiKey")}
onInput={handleInputChange("mistralApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>Mistral API Key</span>
</VSCodeTextField>
@ -339,7 +332,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
value={apiConfiguration?.mistralCodestralUrl || ""}
style={{ width: "100%", marginTop: "10px" }}
type="url"
onBlur={handleInputChange("mistralCodestralUrl")}
onInput={handleInputChange("mistralCodestralUrl")}
placeholder="Default: https://codestral.mistral.ai">
<span style={{ fontWeight: 500 }}>Codestral Base URL (Optional)</span>
</VSCodeTextField>
@ -362,7 +355,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
value={apiConfiguration?.openRouterApiKey || ""}
style={{ width: "100%" }}
type="password"
onBlur={handleInputChange("openRouterApiKey")}
onInput={handleInputChange("openRouterApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>OpenRouter API Key</span>
</VSCodeTextField>
@ -406,7 +399,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
value={apiConfiguration?.openRouterBaseUrl || ""}
style={{ width: "100%", marginTop: 3 }}
type="url"
onBlur={handleInputChange("openRouterBaseUrl")}
onInput={handleInputChange("openRouterBaseUrl")}
placeholder="Default: https://openrouter.ai/api/v1"
/>
)}
@ -444,7 +437,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
<VSCodeTextField
value={apiConfiguration?.awsProfile || ""}
style={{ width: "100%" }}
onBlur={handleInputChange("awsProfile")}
onInput={handleInputChange("awsProfile")}
placeholder="Enter profile name">
<span style={{ fontWeight: 500 }}>AWS Profile Name</span>
</VSCodeTextField>
@ -455,7 +448,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
value={apiConfiguration?.awsAccessKey || ""}
style={{ width: "100%" }}
type="password"
onBlur={handleInputChange("awsAccessKey")}
onInput={handleInputChange("awsAccessKey")}
placeholder="Enter Access Key...">
<span style={{ fontWeight: 500 }}>AWS Access Key</span>
</VSCodeTextField>
@ -463,7 +456,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
value={apiConfiguration?.awsSecretKey || ""}
style={{ width: "100%" }}
type="password"
onBlur={handleInputChange("awsSecretKey")}
onInput={handleInputChange("awsSecretKey")}
placeholder="Enter Secret Key...">
<span style={{ fontWeight: 500 }}>AWS Secret Key</span>
</VSCodeTextField>
@ -471,7 +464,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
value={apiConfiguration?.awsSessionToken || ""}
style={{ width: "100%" }}
type="password"
onBlur={handleInputChange("awsSessionToken")}
onInput={handleInputChange("awsSessionToken")}
placeholder="Enter Session Token...">
<span style={{ fontWeight: 500 }}>AWS Session Token</span>
</VSCodeTextField>
@ -539,7 +532,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
<VSCodeTextField
value={apiConfiguration?.vertexProjectId || ""}
style={{ width: "100%" }}
onBlur={handleInputChange("vertexProjectId")}
onInput={handleInputChange("vertexProjectId")}
placeholder="Enter Project ID...">
<span style={{ fontWeight: 500 }}>Google Cloud Project ID</span>
</VSCodeTextField>
@ -597,7 +590,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
value={apiConfiguration?.geminiApiKey || ""}
style={{ width: "100%" }}
type="password"
onBlur={handleInputChange("geminiApiKey")}
onInput={handleInputChange("geminiApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>Gemini API Key</span>
</VSCodeTextField>
@ -625,7 +618,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
value={apiConfiguration?.openAiBaseUrl || ""}
style={{ width: "100%" }}
type="url"
onBlur={handleInputChange("openAiBaseUrl")}
onInput={handleInputChange("openAiBaseUrl")}
placeholder={"Enter base URL..."}>
<span style={{ fontWeight: 500 }}>Base URL</span>
</VSCodeTextField>
@ -633,7 +626,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
value={apiConfiguration?.openAiApiKey || ""}
style={{ width: "100%" }}
type="password"
onBlur={handleInputChange("openAiApiKey")}
onInput={handleInputChange("openAiApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>API Key</span>
</VSCodeTextField>
@ -676,7 +669,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
<VSCodeTextField
value={apiConfiguration?.azureApiVersion || ""}
style={{ width: "100%", marginTop: 3 }}
onBlur={handleInputChange("azureApiVersion")}
onInput={handleInputChange("azureApiVersion")}
placeholder={`Default: ${azureOpenAiDefaultApiVersion}`}
/>
)}
@ -1126,14 +1119,14 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
value={apiConfiguration?.lmStudioBaseUrl || ""}
style={{ width: "100%" }}
type="url"
onBlur={handleInputChange("lmStudioBaseUrl")}
onInput={handleInputChange("lmStudioBaseUrl")}
placeholder={"Default: http://localhost:1234"}>
<span style={{ fontWeight: 500 }}>Base URL (optional)</span>
</VSCodeTextField>
<VSCodeTextField
value={apiConfiguration?.lmStudioModelId || ""}
style={{ width: "100%" }}
onBlur={handleInputChange("lmStudioModelId")}
onInput={handleInputChange("lmStudioModelId")}
placeholder={"e.g. meta-llama-3.1-8b-instruct"}>
<span style={{ fontWeight: 500 }}>Model ID</span>
</VSCodeTextField>
@ -1195,7 +1188,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
value={apiConfiguration?.deepSeekApiKey || ""}
style={{ width: "100%" }}
type="password"
onBlur={handleInputChange("deepSeekApiKey")}
onInput={handleInputChange("deepSeekApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>DeepSeek API Key</span>
</VSCodeTextField>
@ -1285,14 +1278,14 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage, fromWelcomeView }: A
value={apiConfiguration?.ollamaBaseUrl || ""}
style={{ width: "100%" }}
type="url"
onBlur={handleInputChange("ollamaBaseUrl")}
onInput={handleInputChange("ollamaBaseUrl")}
placeholder={"Default: http://localhost:11434"}>
<span style={{ fontWeight: 500 }}>Base URL (optional)</span>
</VSCodeTextField>
<VSCodeTextField
value={apiConfiguration?.ollamaModelId || ""}
style={{ width: "100%" }}
onBlur={handleInputChange("ollamaModelId")}
onInput={handleInputChange("ollamaModelId")}
placeholder={"e.g. llama3.1"}>
<span style={{ fontWeight: 500 }}>Model ID</span>
</VSCodeTextField>

View file

@ -70,23 +70,13 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined)
const [commandInput, setCommandInput] = useState("")
const handleSubmit = async () => {
// Focus the active element's parent to trigger blur
document.activeElement?.parentElement?.focus()
// Small delay to let blur events complete
await new Promise((resolve) => setTimeout(resolve, 50))
const handleSubmit = () => {
const apiValidationResult = validateApiConfiguration(apiConfiguration)
const modelIdValidationResult = validateModelId(apiConfiguration, glamaModels, openRouterModels)
setApiErrorMessage(apiValidationResult)
setModelIdErrorMessage(modelIdValidationResult)
if (!apiValidationResult && !modelIdValidationResult) {
vscode.postMessage({
type: "apiConfiguration",
apiConfiguration,
})
vscode.postMessage({ type: "alwaysAllowReadOnly", bool: alwaysAllowReadOnly })
vscode.postMessage({ type: "alwaysAllowWrite", bool: alwaysAllowWrite })
vscode.postMessage({ type: "alwaysAllowExecute", bool: alwaysAllowExecute })
@ -201,6 +191,11 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
currentApiConfigName={currentApiConfigName}
listApiConfigMeta={listApiConfigMeta}
onSelectConfig={(configName: string) => {
vscode.postMessage({
type: "saveApiConfiguration",
text: currentApiConfigName,
apiConfiguration,
})
vscode.postMessage({
type: "loadApiConfiguration",
text: configName,

View file

@ -41,6 +41,7 @@ jest.mock("@/components/ui/dialog", () => ({
</div>
),
DialogContent: ({ children }: any) => <div data-testid="dialog-content">{children}</div>,
DialogTitle: ({ children }: any) => <div data-testid="dialog-title">{children}</div>,
}))
describe("ApiConfigManager", () => {

View file

@ -1,4 +1,5 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import React from "react"
import { render, screen, fireEvent } from "@testing-library/react"
import SettingsView from "../SettingsView"
import { ExtensionStateContextProvider } from "../../../context/ExtensionStateContext"
import { vscode } from "../../../utils/vscode"
@ -126,7 +127,7 @@ describe("SettingsView - Sound Settings", () => {
expect(screen.queryByRole("slider", { name: /volume/i })).not.toBeInTheDocument()
})
it("toggles sound setting and sends message to VSCode", async () => {
it("toggles sound setting and sends message to VSCode", () => {
renderSettingsView()
const soundCheckbox = screen.getByRole("checkbox", {
@ -141,14 +142,12 @@ describe("SettingsView - Sound Settings", () => {
const doneButton = screen.getByText("Done")
fireEvent.click(doneButton)
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "soundEnabled",
bool: true,
}),
)
})
expect(vscode.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "soundEnabled",
bool: true,
}),
)
})
it("shows volume slider when sound is enabled", () => {
@ -166,7 +165,7 @@ describe("SettingsView - Sound Settings", () => {
expect(volumeSlider).toHaveValue("0.5")
})
it("updates volume and sends message to VSCode when slider changes", async () => {
it("updates volume and sends message to VSCode when slider changes", () => {
renderSettingsView()
// Enable sound
@ -184,11 +183,9 @@ describe("SettingsView - Sound Settings", () => {
fireEvent.click(doneButton)
// Verify message sent to VSCode
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "soundVolume",
value: 0.75,
})
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "soundVolume",
value: 0.75,
})
})
})
@ -305,7 +302,7 @@ describe("SettingsView - Allowed Commands", () => {
expect(commands).toHaveLength(1)
})
it("saves allowed commands when clicking Done", async () => {
it("saves allowed commands when clicking Done", () => {
const { onDone } = renderSettingsView()
// Enable always allow execute
@ -325,14 +322,12 @@ describe("SettingsView - Allowed Commands", () => {
fireEvent.click(doneButton)
// Verify VSCode messages were sent
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "allowedCommands",
commands: ["npm test"],
}),
)
expect(onDone).toHaveBeenCalled()
})
expect(vscode.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "allowedCommands",
commands: ["npm test"],
}),
)
expect(onDone).toHaveBeenCalled()
})
})

View file

@ -6,24 +6,22 @@ import { vscode } from "../../utils/vscode"
import ApiOptions from "../settings/ApiOptions"
const WelcomeView = () => {
const { apiConfiguration } = useExtensionState()
const { apiConfiguration, currentApiConfigName } = useExtensionState()
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined)
const handleSubmit = async () => {
// Focus the active element's parent to trigger blur
document.activeElement?.parentElement?.focus()
// Small delay to let blur events complete
await new Promise((resolve) => setTimeout(resolve, 50))
const handleSubmit = () => {
const error = validateApiConfiguration(apiConfiguration)
if (error) {
setErrorMessage(error)
return
}
setErrorMessage(undefined)
vscode.postMessage({ type: "apiConfiguration", apiConfiguration })
vscode.postMessage({
type: "upsertApiConfiguration",
text: currentApiConfigName,
apiConfiguration,
})
}
return (

View file

@ -162,26 +162,12 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const handleInputChange = useCallback(
// Returns a function that handles an input change event for a specific API configuration field.
// The optional "softUpdate" flag determines whether to immediately update local state or send an external update.
(field: keyof ApiConfiguration, softUpdate?: boolean) => (event: any) => {
(field: keyof ApiConfiguration) => (event: any) => {
// Use the functional form of setState to ensure the latest state is used in the update logic.
setState((currentState) => {
if (softUpdate) {
// Return a new state object with the updated apiConfiguration.
// This will trigger a re-render with the new configuration value.
return {
...currentState,
apiConfiguration: { ...currentState.apiConfiguration, [field]: event.target.value },
}
} else {
// For non-soft updates, send a message to the VS Code extension with the updated config.
// This side effect communicates the change without updating local React state.
vscode.postMessage({
type: "upsertApiConfiguration",
text: currentState.currentApiConfigName,
apiConfiguration: { ...currentState.apiConfiguration, [field]: event.target.value },
})
// Return the unchanged state as no local state update is intended in this branch.
return currentState
return {
...currentState,
apiConfiguration: { ...currentState.apiConfiguration, [field]: event.target.value },
}
})
},